Ver Planos Entrar
25 de maio de 2026 - Eduardo Portumati

Channels em C#: como evitar gargalos em APIs de alto volume

Os channels trabalham com o conceito de producers, que produzem (obviamente) dados, e consumers que consomem (ah vá) os dados, ambos de forma assíncrona.

Mas qual é a dele?

Até aí, nada de especial, muitas estruturas fazem isso.
Qual a diferença de uma lista ou de um blocking collection?

A grande diferença é a eficiência thread-safe e o controle de backpressure, evitando esgotamento de recursos e bloqueio de threads.
Ele foi criado para cenários de stress, de alto throughput.

Podemos imaginar um Channel como um pipe:

[ Producers ] —> ( Channel / Buffer ) —> [ Consumers ]

  • Producers escrevem dados;
  • Consumers leem dados;
  • O Channel controla o fluxo entre eles.

Criando um channel limitado.

// Criando um canal limitado (Bounded)
var options = new BoundedChannelOptions(capacity: 1000)
{
    // Define o que acontece quando o canal está cheio
    FullMode = BoundedChannelFullMode.DropOldest, // .Wait / .DropNewest / .DropOldest / .DropWrite
    SingleReader = true,  // Otimização se houver apenas um Consumer
    SingleWriter = false  // Múltiplos produtores (ex: várias instâncias da API)
};
Channel auditChannel = Channel.CreateBounded(options);

Uso de recursos primitivos, assíncronos e thread-safe

Primeiro, Channels usam ValueTasks na maioria das operações, como ValueTask é um struct, ele pode evitar a criação de uma Task quando a operação for síncrona.
Reduzindo a alocação na heap. Ou seja, ganho de performance (reduz pressão no GC).

Segundo, você pode limitar o tamanho da fila (bounded channels), evitando falta de recursos.
Se a fila chegar no limite, você decide como agir. O producer fica aguardando liberar um slot (Wait – padrão), descartar novos dados (DropNewest), descartar o começo da fila (DropOldest) ou descartar a escrita atual (DropWrite).

Comparação rápida:

  • Lista comum (List / Queue) -> são rápidas, mas não trabalham com concorrência. Se 2 produtores tentarem escrever juntos, vai estourar exceção ou corromper o estado.
  • ConcurrentQueue -> thread-safe, mas não “avisa” o consumer, que precisa ficar buscando por mudanças manualmente.
  • BlockingCollection -> bloqueia a thread enquanto envia ou recebe o dado, apesar de resolver concorrência (usa bloqueio de thread, ao invés de await).

Combo

Eles são ótimos para combinarmos com outro recurso interessante do .NET: Background Services.
(esse artigo está ficando maior do que eu queria, mas continue, vale a pena)

Onde usar?

Exemplo clássico: auditoria.

Imagine que você precisa registrar todas as chamadas da sua API, que tem milhares de requisições por minuto.

Se o seu middleware gravar a informação a cada request, você vai aumentar o tempo de resposta por causa do I/O de banco de dados, que vai se tornar um gargalo.
Você poderia ir na opção ingênua e usar um Fire & Forget?
Poderia.
Deveria? Não.
Você perderia o controle da concorrência e poderia perder o processo por falta de recursos.

Então você vai usar Channels+Background services, colocando os eventos na esteira e tratando de forma assíncrona.

Colocando os eventos no pipe:

public class AuditoriaWorker(
    IAuditoriaQueue queue,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    private const int BatchSize = 30;
    private readonly TimeSpan _maxWaitTime = TimeSpan.FromSeconds(10);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var buffer = new List(BatchSize);

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // WaitToReadAsync aguarda de forma não bloqueante até que haja algo no Channel
                // ou até que o timeout de _maxWaitTime expire.
                using var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
                cts.CancelAfter(_maxWaitTime);

                try
                {
                    if (await queue.Reader.WaitToReadAsync(cts.Token))
                    {
                        // Se houver dados, tentamos encher o buffer o máximo possível
                        // sem esperar (TryRead), aproveitando o que já está na memória.
                        while (buffer.Count  0)
                {
                    await FlushBatchAsync(buffer, stoppingToken);
                    buffer.Clear();
                }
            }
            catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
            {
                await Task.Delay(5000, stoppingToken);
            }
        }
    }

    private async Task FlushBatchAsync(List logs, CancellationToken ct)
    {
        // Processamento do batch (ex: salvar no banco)
    }
}

Você pode, inclusive, apenas registrar os logs (gravar num banco, por exemplo), quando a fila alcançar uma quantidade configurada de registros, assim você usa um bulk insert, evitando idas desnecessárias ao banco de dados.
(convém configurar um tempo máximo de espera, para evitar espera eterna)

Para uma leitura simples, item a item, você usará ReadAllAsync.
Cada evento recebido no Channel, você processa individualmente.

Para controlar manualmente a fila, você pode usar WaitToReadAsync, ideal para cenários de tratamento em lote (bulk/batching).
Com ele, você pode aguardar até que tenha pelo menos um item disponível antes de iniciar a leitura.

Consumindo e tratando em lote

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (await _reader.WaitToReadAsync(stoppingToken))
    {
        var batch = new List();

        // Tenta pegar o que estiver disponível imediatamente até um limite (ex: 50 itens)
        while (batch.Count < 50 && _reader.TryRead(out var log))
            batch.Add(log);

        if (batch.Any())
            await _repository.BulkInsertAsync(batch);
    }
}

O que mais?

Channels são muito úteis para pipelines internos de processamento dentro da aplicação, especialmente quando não há necessidade de persistência da fila ou comunicação entre múltiplos serviços, quando não há necessidade de uma fila distribuída como Kafka ou RabbitMQ.
Importante: Channels não persistem dados! Se o processo morrer, os dados em fila serão perdidos.

Resumindo:

Channels são um recurso simples de ser usado, eficiente e pouco explorado.

Oferecem uma forma simples de desacoplar produção e consumo de eventos, sem perder o controle sobre a concorrência e consumo de recursos.

Se usarmos com Background Services, podem evitar o uso de filas distribuídas em casos de pipelines internos, de forma performática.