Controle de concorrência em C#: Semaphore e SemaphoreSlim

O SemaphoreSlim é a forma mais simples de limitar quantas tarefas executam ao mesmo tempo em uma aplicação C#. Normalmente, queremos que a aplicação processe o máximo possível de uma vez, mas há momentos em que você precisa segurar esse ritmo.

Imagine que a API que você chama aceita poucas requisições simultâneas. Ou, mais raramente, que você precisa acessar um recurso de hardware com um número limitado de acessos ao mesmo tempo.

Nesses casos, o async e o await sozinhos não bastam, principalmente quando várias threads entram em jogo. Por isso, o .NET oferece duas estruturas de controle de concorrência: o Semaphore e, ainda melhor para a maioria dos casos, o SemaphoreSlim.

O que é um semáforo na programação

Um semáforo funciona como um guarda de trânsito. Ele define quem passa e quem aguarda, mantendo um contador de vagas disponíveis.

Cada vez que uma tarefa entra, o contador diminui. Quando a tarefa sai e devolve a vaga, o contador aumenta, e outra tarefa que estava esperando pode entrar.

Quando o contador chega a zero, as próximas tarefas ficam bloqueadas até alguém liberar uma vaga. Dessa forma, o número de execuções simultâneas nunca passa do limite definido.

Segundo a documentação oficial de Semaphore e SemaphoreSlim, não existe ordem garantida para a entrada. Ou seja, nem a primeira tarefa que chegou é necessariamente a primeira a entrar.

SemaphoreSlim: prático e leve

O SemaphoreSlim é a versão mais leve e moderna. Ele foi otimizado para controlar a concorrência dentro de uma mesma aplicação e tem excelente suporte a async e await.

Internamente, o SemaphoreSlim usa ao máximo as primitivas de sincronização do próprio runtime. Assim, ele evita recorrer ao sistema operacional, o que o torna mais rápido que o Semaphore tradicional.

Além disso, o SemaphoreSlim aceita CancellationToken e tempo limite de espera. Por outro lado, ele não suporta semáforos nomeados, então funciona apenas dentro de um único processo.

Um caso real para o SemaphoreSlim

Imagine o seguinte cenário hipotético. Você precisa buscar todo dia a previsão do tempo de todas as cidades do Brasil, mas o provedor da API permite apenas três acessos simultâneos.

Se você disparar milhares de requisições de uma vez, a API vai recusar a maioria delas. Por outro lado, fazer uma requisição por vez desperdiça tempo.

O SemaphoreSlim resolve exatamente esse meio termo. Com ele, você acessa a API o mais rápido possível, sem passar de três chamadas ao mesmo tempo.

Como usar o SemaphoreSlim passo a passo

Primeiro, criamos a instância do SemaphoreSlim, definindo que o máximo será de três execuções simultâneas.

var semaphore = new SemaphoreSlim(3);

Em seguida, criamos uma lista de tarefas, que vai guardar todas as chamadas em andamento.

var tasks = new List<Task>();

Então, adicionamos as tarefas à lista. Cada tarefa usa o WaitAsync do SemaphoreSlim para aguardar a autorização de execução.

tasks.Add(ProcessForecastAsync(city));

A tarefa termina sempre com Release, dentro do bloco finally. Isso garante que a vaga volte para o semáforo, mesmo em caso de erro.

finally { semaphore.Release(); }

Por último, depois da iteração, usamos o Task.WhenAll, do namespace System.Threading.Tasks, para aguardar a conclusão de todas as tarefas.

await Task.WhenAll(tasks);

Durante a execução, o SemaphoreSlim atua como um portão. As tarefas pedem permissão pelo WaitAsync, e ele garante que no máximo três trabalhem ao mesmo tempo, enquanto as outras esperam a vez.

Exemplo completo com SemaphoreSlim

Juntando as partes, o código fica assim:

var cities = GetCities();
var semaphore = new SemaphoreSlim(3);
using var httpClient = new HttpClient();
var tasks = new List<Task>();

foreach (var city in cities)
{
    tasks.Add(ProcessForecastAsync(city));
}

await Task.WhenAll(tasks);
Console.WriteLine("Forecasts processed.");

async Task ProcessForecastAsync(City city)
{
    // Aguarda uma vaga livre no semáforo
    await semaphore.WaitAsync();
    try
    {
        var url = $"https://api.example.com/forecast/{city.State.ToLower()}/{city.Name.ToLower()}";
        Console.WriteLine($"[START] {city.Name}, {city.State} {DateTime.Now:HH:mm:ss}");

        var response = await httpClient.GetAsync(url);
        response.EnsureSuccessStatusCode();

        var forecast = await response.Content.ReadAsStringAsync();

        Console.WriteLine($"[END] {city.Name}, {city.State} {DateTime.Now:HH:mm:ss}");
    }
    catch (HttpRequestException ex)
    {
        Console.WriteLine($"[ERROR] Failed to fetch {city.Name}: {ex.Message}");
    }
    finally
    {
        // Devolve a vaga, mesmo se ocorrer uma exceção
        semaphore.Release();
    }
}

record City(string Name, string State);

O método ProcessForecastAsync está no mesmo escopo da variável semaphore, por isso consegue acessá-la diretamente. Em outros contextos, talvez você precise passar o semáforo como parâmetro ou registrá-lo na injeção de dependência.

Repare também que todas as tarefas são criadas de uma vez. Contudo, apenas três avançam além do WaitAsync, e as demais ficam aguardando sem bloquear nenhuma thread.

Vale ressaltar que o SemaphoreSlim não é restrito a listas. Na verdade, esse é o grande diferencial dele em relação ao Parallel.ForEachAsync, da Task Parallel Library (TPL), que trabalha sobre coleções.

Recursos extras do SemaphoreSlim

Além do uso básico, o SemaphoreSlim traz alguns recursos que ajudam em cenários reais. Conhecer cada um evita gambiarras no código.

Tempo limite e cancelamento

O WaitAsync aceita um tempo limite e um CancellationToken. Com o tempo limite, o método retorna false quando não consegue a vaga a tempo.

// Espera no máximo 5 segundos por uma vaga
if (!await semaphore.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken))
{
    Console.WriteLine("No slot available, try again later.");
    return;
}

try
{
    await CallExternalApiAsync(cancellationToken);
}
finally
{
    semaphore.Release();
}

Nesse caso, o Release fica dentro do try que vem depois da espera. Afinal, se a tarefa não conseguiu a vaga, ela não pode devolver uma vaga que nunca recebeu.

Da mesma forma, se o token for cancelado durante a espera, o WaitAsync lança OperationCanceledException. Portanto, mantenha a chamada de espera fora do bloco protegido pelo finally.

SemaphoreSlim como lock assíncrono

A palavra-chave lock do C# não permite await dentro do bloco. Por isso, um SemaphoreSlim com uma única vaga é a solução mais comum para exclusão mútua em código assíncrono.

private static readonly SemaphoreSlim _cacheLock = new(1, 1);

public async Task<Report> GetReportAsync()
{
    await _cacheLock.WaitAsync();
    try
    {
        // Apenas uma tarefa por vez atualiza o cache
        return _cachedReport ??= await BuildReportAsync();
    }
    finally
    {
        _cacheLock.Release();
    }
}

O segundo parâmetro do construtor define a contagem máxima. Assim, se algum código chamar Release a mais, o semáforo lança SemaphoreFullException em vez de abrir uma segunda vaga silenciosamente.

Propriedades úteis

A propriedade CurrentCount informa quantas vagas estão livres no momento. Ela ajuda em logs e diagnósticos, mas não deve guiar decisões, porque o valor pode mudar logo depois da leitura.

Além disso, o SemaphoreSlim implementa IDisposable. Portanto, descarte a instância quando ela não for mais necessária, principalmente se ela foi criada por requisição.

SemaphoreSlim na injeção de dependência

Em uma API ASP.NET Core, o semáforo precisa ser compartilhado por todas as requisições. Por isso, o caminho mais seguro é encapsulá-lo em um serviço registrado como singleton.

public sealed class WeatherApiThrottler : IDisposable
{
    // Limite compartilhado por todas as requisições da aplicação
    private readonly SemaphoreSlim _semaphore = new(3, 3);

    public async Task<T> RunAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken)
    {
        await _semaphore.WaitAsync(cancellationToken);
        try
        {
            return await action();
        }
        finally
        {
            _semaphore.Release();
        }
    }

    public void Dispose() => _semaphore.Dispose();
}

builder.Services.AddSingleton<WeatherApiThrottler>();

Se você registrasse o serviço como scoped ou transient, cada requisição receberia um semáforo próprio e independente. Nesse caso, o limite de três chamadas deixaria de valer para a aplicação como um todo.

Além disso, o método recebe o CancellationToken da requisição. Assim, quando o cliente desiste, a tarefa sai da fila de espera sem ocupar uma vaga.

Semaphore: mais antigo e mais robusto

Na maioria das vezes, você não vai precisar do Semaphore. Ele é mais pesado, porque é um invólucro fino sobre o objeto de semáforo do Win32 e conversa diretamente com o sistema operacional.

O grande diferencial dele é criar semáforos nomeados, registrados no sistema operacional. Com isso, processos distintos compartilham o mesmo controle de concorrência.

Imagine vários serviços ou aplicações, como diferentes Application Pools do IIS, acessando um recurso compartilhado e limitado. Nesse cenário, o SemaphoreSlim não ajuda, porque cada processo teria o seu próprio contador.

Importante: você só controla as aplicações que usam o mesmo semáforo nomeado. Um processo que não conhece o nome continua livre para acessar o recurso.

Um caso real para o Semaphore

Imagine que você está criando sua própria plataforma de vídeos, mas o servidor não consegue converter muitos vídeos ao mesmo tempo. Cada vídeo enviado precisa de cinco codificações, em 240p, 360p, 720p, 1080p e 2160p.

Ou seja, cada usuário gera cinco processos de conversão. Além disso, o servidor usa várias Application Pools, então as conversões rodam em processos diferentes.

Nesse caso, você precisa de um semáforo global, que controle todos os processos. A chave para compartilhá-lo é que todos usem o mesmo nome.

var semaphoreGlobalName = @"Global\VideoConversionSemaphore";

O prefixo Global\ coloca o semáforo no namespace global do Windows. Assim, ele fica visível para processos de todas as sessões do servidor.

Criando o Semaphore nomeado

Para criar o semáforo global, você define o número inicial de vagas, o número máximo e o nome. O construtor também devolve um booleano que indica se um semáforo novo foi criado ou se um existente foi reaproveitado.

var semaphore = new Semaphore(initialCount, maximumCount, semaphoreGlobalName, out bool createdNew);

Em seguida, criamos as tarefas de conversão de vídeo. O exemplo repete código de propósito, apenas para ficar ilustrativo, mas vale revisar os princípios SOLID antes de levar algo parecido para produção.

var semaphoreGlobalName = @"Global\VideoConversionSemaphore";

var initialCount = 2;
var maximumCount = 2;

using var semaphore = new Semaphore(initialCount, maximumCount, semaphoreGlobalName, out bool createdNew);

Console.WriteLine(createdNew ? "New global semaphore created." : "Connected to existing semaphore.");

var t240 = Convert240pAsync();
var t360 = Convert360pAsync();
var t720 = Convert720pAsync();
var t1080 = Convert1080pAsync();
var t2160 = Convert2160pAsync();

await Task.WhenAll(t240, t360, t720, t1080, t2160);

Console.WriteLine("All conversions finished.");

Assim como no SemaphoreSlim, usamos o Task.WhenAll para aguardar todas as tarefas. O restante segue a mesma lógica: aguardar a vaga, executar e liberar.

Aguardando e liberando vagas no Semaphore

As tarefas controladas pelo Semaphore usam WaitOne() para aguardar e Release() para liberar. Porém, o WaitOne é síncrono e bloqueia a thread que o chama.

Por isso, é importante envolver o WaitOne em um Task.Run. Isso move a espera para o thread pool, mas não torna a operação verdadeiramente assíncrona, já que uma thread do pool continua bloqueada.

async Task Convert240pAsync()
{
    // Move a espera bloqueante para o thread pool
    await Task.Run(() => semaphore.WaitOne());
    try
    {
        // lógica de conversão 240p
    }
    finally
    {
        semaphore.Release();
    }
}

Nesse código, a thread que libera pode ser diferente da que aguardou. Isso não é problema, porque nenhum dos dois tipos de semáforo exige que a mesma thread faça as duas operações.

Limitações e segurança

Atenção a um ponto de portabilidade. Os semáforos nomeados são um recurso do Windows, e a documentação informa que eles não são suportados em sistemas baseados em Unix.

Também existe um risco de segurança. Como o nome é visível em todo o sistema, outro processo pode usar o mesmo nome e ocupar as vagas, inclusive de forma maliciosa.

Para reduzir esse risco, a Microsoft recomenda proteger o semáforo com controle de acesso, usando SemaphoreSecurity. Ainda assim, isso não evita colisões acidentais de nome, então escolha nomes bem específicos.

SemaphoreSlim ou Semaphore: qual escolher

A tabela abaixo resume as diferenças entre os dois tipos.

Característica SemaphoreSlim Semaphore
Escopo Um único processo Vários processos, quando nomeado
Peso Leve, usa primitivas do runtime Mais pesado, usa o sistema operacional
Suporte a async Sim, com WaitAsync Não, apenas WaitOne
Cancelamento Sim, com CancellationToken Não
Plataformas Todas Nomeado apenas no Windows
Controle de acesso Não Sim, com SemaphoreSecurity

De forma simplificada, use o Semaphore quando precisar coordenar processos diferentes ou aplicar regras de segurança. Em todos os outros casos, o SemaphoreSlim atende melhor, porque é leve, compatível com async e await e controla tarefas dentro ou fora de listas.

Quando o problema é distribuído entre várias máquinas, nenhum dos dois resolve. Nesse caso, o controle precisa de um recurso externo, como um cache distribuído ou um rate limiter na própria API, tema do artigo sobre rate limiting no ASP.NET Core 10.

Erros comuns com SemaphoreSlim

O primeiro erro é esquecer o finally. Se uma exceção acontecer antes do Release, a vaga nunca volta, e o semáforo vai perdendo capacidade até travar a aplicação.

O segundo erro é chamar Release sem ter conseguido a vaga. Isso acontece quando o WaitAsync fica dentro do try, e um cancelamento ou tempo limite faz o finally liberar uma vaga inexistente.

Outro erro comum é criar um SemaphoreSlim novo a cada chamada do método. Nesse caso, cada chamada ganha um semáforo próprio, e o limite simplesmente deixa de existir.

Por fim, evite misturar Wait síncrono com código assíncrono. O Wait bloqueia a thread e pode causar esgotamento do thread pool em aplicações ASP.NET Core, enquanto o WaitAsync libera a thread durante a espera.

Se o seu cenário envolve muitas mensagens passando entre produtores e consumidores, considere também os channels. O artigo sobre channels em C# mostra como eles controlam a vazão de forma natural.

E o Parallel.ForEachAsync?

Existe ainda a opção da TPL com Parallel.ForEachAsync, voltada para tarefas iteradas sobre uma coleção. Ele aceita um MaxDegreeOfParallelism, que limita quantos itens são processados ao mesmo tempo.

Para listas, essa abordagem costuma gerar menos código. Contudo, o SemaphoreSlim continua útil quando as tarefas surgem de lugares diferentes da aplicação, e não de uma única coleção.

O foco aqui é mostrar o controle de concorrência com Semaphore e SemaphoreSlim. Assim, a comparação detalhada com a TPL fica para um próximo artigo.

Conclusão

O SemaphoreSlim é a escolha padrão para limitar execuções simultâneas dentro de uma aplicação C#. Ele é leve, funciona com async e await, aceita cancelamento e se adapta tanto a listas quanto a tarefas espalhadas pelo código.

Já o Semaphore tem um papel mais específico. Ele brilha quando processos diferentes precisam respeitar o mesmo limite, desde que a aplicação rode no Windows.

Em ambos os casos, a regra de ouro é a mesma: aguarde a vaga antes do try e libere sempre no finally. Com isso, o semáforo mantém a contagem correta durante toda a vida da aplicação.

FAQ

Qual a diferença entre SemaphoreSlim e lock?

O lock permite apenas uma thread por vez e não aceita await dentro do bloco. O SemaphoreSlim permite definir várias vagas e funciona com código assíncrono pelo WaitAsync.

O SemaphoreSlim garante a ordem de execução?

Não. A documentação informa que não há ordem garantida para a entrada das tarefas bloqueadas.

Posso usar Semaphore nomeado no Linux?

Não. Os semáforos nomeados não são suportados em sistemas baseados em Unix, então esse recurso fica restrito ao Windows.

O que acontece se eu chamar Release demais?

Se o semáforo tiver uma contagem máxima definida, ele lança SemaphoreFullException. Sem contagem máxima explícita, o semáforo cria a vaga extra silenciosamente e o limite deixa de valer.

SemaphoreSlim funciona entre várias instâncias da API?

Não. Ele vale apenas dentro de um processo, então várias instâncias precisam de um controle externo, como um cache distribuído.

Glossário

Termo Significado
Semáforo Estrutura de sincronização que limita quantas execuções acessam um recurso ao mesmo tempo
SemaphoreSlim Semáforo leve do .NET, restrito a um processo e compatível com async e await
Semaphore Semáforo do .NET baseado no sistema operacional, que pode ser nomeado e compartilhado entre processos
Semáforo nomeado Semáforo registrado no sistema operacional com um nome, visível para vários processos
WaitAsync Método do SemaphoreSlim que aguarda uma vaga sem bloquear a thread
WaitOne Método do Semaphore que aguarda uma vaga bloqueando a thread
Release Método que devolve uma vaga ao semáforo
Thread pool Conjunto de threads reutilizáveis gerenciado pelo runtime do .NET
TPL Task Parallel Library, conjunto de APIs do .NET para programação paralela
SemaphoreFullException Exceção lançada quando o semáforo recebe mais liberações que a contagem máxima

Fontes