Gerenciamento de Tarefas em Segundo Plano: Superando as Limitações do ThreadPool no C#

No desenvolvimento de aplicações web de alta concorrência, delegar operações demoradas para execução em segundo plano é uma prática essencial para manter a responsividade da interface. Uma abordagem inicial comum envolve a instancaição direta de novas threads para cada requisição recebida:

// Abordagem ingênua: criação excessiva de threads
new Thread(() => 
{
    // Processamento pesado
}).Start();

Em abmientes com alto volume de tráfego, essa estratégia é insustentável. A sobrecarga gerada pela criação e destruição contínua de threads rapidamente exaure os recursos do processador, podendo levar a aplicação à falha completa. O mecanismo padrão para mitigar este problema no .NET é o ThreadPool.QueueUserWorkItem, que reutiliza threads ociosas e gerencia uma fila interna de execução.

// Utilizando o pool de threads nativo
ThreadPool.QueueUserWorkItem(state => 
{
    // Lógica de negócio
}, null);

Embora o ThreadPool seja significativamente mais eficiente, ele ainda enfrenta desafios sob cargas extremas. O pool pode expandir o número de threads ativas para atender à demanda de forma agressiva, o que continua gerando alto consumo de memória e alternância de contexto (context switching) no sistema operacional.

Uma alternativa arquitetural para cenários onde o controle estrito de recursos é prioritário consiste em implementar o padrão Produtor-Consumidor utilizando uma única thread de trabalho dedicada. Isso elimina completamente a sobrecarga de múltiplas threads, procsesando as tarefas de forma sequencial. Abaixo, apresentamos uma implementação moderna utilizando BlockingCollection<T> e CancellationToken, substituindo o uso ineficiente de Thread.Sleep e blocos lock manuais encontrados em implementações legadas.

using System;
using System.Collections.Concurrent;
using System.Threading;

public class SequentialTaskExecutor : IDisposable
{
    private readonly BlockingCollection<workitem> _taskQueue = new BlockingCollection<workitem>();
    private readonly Thread _workerThread;
    private readonly CancellationTokenSource _cts = new CancellationTokenSource();

    private class WorkItem
    {
        public Action<object> Action { get; }
        public object State { get; }

        public WorkItem(Action<object> action, object state)
        {
            Action = action;
            State = state;
        }
    }

    public SequentialTaskExecutor()
    {
        _workerThread = new Thread(ProcessQueue)
        {
            IsBackground = true,
            Name = "SequentialWorker"
        };
        _workerThread.Start();
    }

    private void ProcessQueue()
    {
        try
        {
            // GetConsumingEnumerable bloqueia a thread nativamente sem consumir CPU quando a fila está vazia
            foreach (var item in _taskQueue.GetConsumingEnumerable(_cts.Token))
            {
                try
                {
                    item.Action(item.State);
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"[SequentialTaskExecutor] Erro na tarefa: {ex.Message}");
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Encerramento gracioso da thread
        }
    }

    public void Enqueue(Action<object> action, object state)
    {
        if (!_taskQueue.IsAddingCompleted)
        {
            _taskQueue.Add(new WorkItem(action, state));
        }
    }

    public void Dispose()
    {
        _taskQueue.CompleteAdding();
        _cts.Cancel();
        _workerThread.Join();
        _cts.Dispose();
        _taskQueue.Dispose();
    }
}</object></object></object></workitem></workitem>

Essa implementação garante que apenas uma thread esteja ativa consumindo a fila. A utilização é direta:

using (var executor = new SequentialTaskExecutor())
{
    executor.Enqueue(state => 
    {
        Console.WriteLine($"Tarefa processada em: {state}");
    }, DateTime.UtcNow);
}

Para cenários que exigem processadores dedicados e fortemente tipados por tipo de dado, a solução pode ser generalizada utilizando genéricos:

using System;
using System.Collections.Concurrent;
using System.Threading;

public class TypedTaskProcessor<t> : IDisposable
{
    private readonly Action<t> _handler;
    private readonly BlockingCollection<t> _queue = new BlockingCollection<t>();
    private readonly Thread _worker;
    private readonly CancellationTokenSource _cts = new CancellationTokenSource();

    public TypedTaskProcessor(Action<t> handler)
    {
        _handler = handler ?? throw new ArgumentNullException(nameof(handler));
        
        _worker = new Thread(Consume)
        {
            IsBackground = true
        };
        _worker.Start();
    }

    private void Consume()
    {
        try
        {
            foreach (var payload in _queue.GetConsumingEnumerable(_cts.Token))
            {
                try
                {
                    _handler(payload);
                }
                catch 
                { 
                    // Tratamento de exceções do domínio
                }
            }
        }
        catch (OperationCanceledException) { }
    }

    public void Submit(T data)
    {
        if (!_queue.IsAddingCompleted)
        {
            _queue.Add(data);
        }
    }

    public void Dispose()
    {
        _queue.CompleteAdding();
        _cts.Cancel();
        _worker.Join();
        _queue.Dispose();
        _cts.Dispose();
    }
}</t></t></t></t></t>

Exemplo de submissão de carga para processamento em lote de entidades:

public class BlogPost 
{ 
    public int Id { get; set; } 
}

var processor = new TypedTaskProcessor<blogpost>(post => 
{
    Console.WriteLine($"Indexando post ID: {post.Id}");
});

for (int i = 1; i <= 1000; i++)
{
    processor.Submit(new BlogPost { Id = i });
}</blogpost>

A arquitetura baseada em fila de thread única oferece controle absoluto sobre a alocação de recursos do sistema. No entanto, a execução estritamente sequencial introduz latência acumulativa para itens posicionados no final de filas extensas. Adicionalmente, o armazenamento da fila em memória volátil implica que interrupções abruptas do processo hospedeiro, como reinicializações do IIS, resultarão na perda imediata de qualquer carga de trabalho pendente de processamento.

Tags: C# ThreadPool Multithreading BlockingCollection ProducerConsumer

Publicado em 7-10 07:32