CodeGym /Cursos /C# SELF /Padrões de thread-safety

Padrões de thread-safety

C# SELF
Nível 58 , Lição 4
Disponível

1. Padrão “Sem bloqueio” (Lock-Free / Wait-Free)

Você já sabe que coleções Concurrent são thread-safe. Mas como elas fazem isso sem você encher o código de blocos lock? A resposta está em algoritmos avançados — lock-free (sem bloqueios) e wait-free (sem espera).

Breve explicação dos conceitos de algoritmos lock-free e wait-free

Lock-Free (sem bloqueios)

Essência: Garante que pelo menos uma thread sempre consiga avançar, mesmo se outras tiverem atrasos ou forem interrompidas.

Diferença em relação a lock: Com lock threads concorrentes ficam esperando pela liberação do bloqueio. Em algoritmos lock-free as threads não esperam umas pelas outras no sentido clássico: em caso de conflito elas simplesmente tentam de novo.

Exemplo: Fila no caixa: com lock você fica parado esperando. No lock-free você chega, vê que está ocupado e tenta de novo em um instante — sem “ficar na fila” global.

Wait-Free (sem espera)

Essência: Garantia mais forte: cada thread avançará em um número finito de passos próprios, independente das outras. Ninguém fica “girando” infinitamente.

Diferença: Em lock-free uma thread pode reiniciar a operação indefinidamente por causa de conflitos; em wait-free isso não acontece.

Prática: Implementar wait-free é bem mais complexo, então na prática vemos mais lock-free ou abordagens híbridas.

2. Como coleções Concurrent alcançam thread-safety

O bloco de construção principal dos algoritmos lock-free são operações atômicas suportadas pela CPU. No .NET acessamos isso via a classe System.Threading.Interlocked.

Operações Interlocked

Operações atômicas rápidas sobre primitivos (int, long): por exemplo, Interlocked.Increment, Interlocked.Decrement, Interlocked.CompareExchange.

Exemplos: Interlocked.Increment(ref value) — incremento atômico; Interlocked.CompareExchange(ref location, value, comparand) — compara atômica e, se igual, atualiza.

CAS (Compare-And-Swap) — Comparar-e-Trocar

A operação CAS é implementada no .NET como Interlocked.CompareExchange. Lógica geral:

  1. Ler o valor atual da variável.
  2. Calcular o novo valor com base no valor lido.
  3. Tentar escrever ele apenas se a variável ainda for igual ao valor original. Se não for — repetir a tentativa.

Exemplo: contador simples lock-free com Interlocked

using System.Threading;
using System.Threading.Tasks;

class CounterExample
{
    static int regularCounter = 0;
    static int interlockedCounter = 0;

    static void IncrementRegular(int iterations)
    {
        for (int i = 0; i < iterations; i++)
        {
            regularCounter++; // Não é thread-safe!
        }
    }

    static void IncrementInterlocked(int iterations)
    {
        for (int i = 0; i < iterations; i++)
        {
            Interlocked.Increment(ref interlockedCounter); // Atômico!
        }
    }
}

//No Main:
Task t1 = Task.Run(() => IncrementRegular(500_000));
Task t2 = Task.Run(() => IncrementRegular(500_000));
Task.WaitAll(t1, t2);
Console.WriteLine($"Contador normal: {regularCounter}"); // quase sempre será menor que 1_000_000

regularCounter = 0; // Reset para o próximo teste
t1 = Task.Run(() => IncrementInterlocked(500_000));
t2 = Task.Run(() => IncrementInterlocked(500_000));
Task.WaitAll(t1, t2);
Console.WriteLine($"Contador Interlocked: {interlockedCounter}"); // Será exatamente 1_000_000

O método Interlocked.Increment garante atomicidade do incremento: os dados não são perdidos mesmo com acesso concorrente de várias threads.

3. Por que isso importa para escalabilidade e performance

Reduz overhead: locks clássicos (lock) podem causar trocas de contexto e esperas no kernel do SO. Lock-free minimiza esses custos.

Sem deadlocks: threads não ficam esperando umas às outras — não há chance de deadlock por essa causa.

Melhor escalabilidade: em sistemas multicore as threads atrapalham menos umas às outras, não existe um “gargalo” de um único lock global.

Maior responsividade: ninguém fica “travado” em esperas longas.

Olhar rápido por dentro do ConcurrentQueue<T>

Simplificando: a fila é composta por segmentos encadeados. No Enqueue a thread avança o “tail” de forma atômica via CompareExchange; no TryDequeue desloca a “head” atômicamente somente se ela não mudou. Implementações reais são mais complexas (lidam com o problema ABA e com garbage collection), mas o ponto-chave é usar operações atômicas em vez de locks “pesados”.

4. Performance das coleções Concurrent

Comparação de performance com lock em coleções comuns

Com baixa concorrência a diferença é pequena, e às vezes um simples lock numa coleção comum pode ser equivalente. Mas com alta concorrência coleções Concurrent tendem a ser significativamente mais rápidas por não haver esperas no lock global.

Exemplo: comparação (ideia, sem executar)

using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Diagnostics; // Para Stopwatch
using System.Threading.Tasks;

class PerformanceTest
{
    static List<int> regularList = new List<int>();
    static ConcurrentQueue<int> concurrentQueue = new ConcurrentQueue<int>();
    static object lockObject = new object();

    const int Iterations = 1_000_000;
    const int NumTasks = 4; // Quantidade de tasks paralelas

    public static void RunTests()
    {
        Console.WriteLine("Teste de performance (adicionar):");

        // Teste com List comum e lock
        regularList.Clear();
        Stopwatch sw = Stopwatch.StartNew();
        Parallel.For(0, NumTasks, (i) =>
        {
            for (int j = 0; j < Iterations / NumTasks; j++)
            {
                lock (lockObject)
                {
                    regularList.Add(j);
                }
            }
        });
        sw.Stop();
        Console.WriteLine($"List com lock: {sw.ElapsedMilliseconds} ms. Count: {regularList.Count}");

        // Teste com ConcurrentQueue
        concurrentQueue.Clear();
        sw = Stopwatch.StartNew();
        Parallel.For(0, NumTasks, (i) =>
        {
            for (int j = 0; j < Iterations / NumTasks; j++)
            {
                concurrentQueue.Enqueue(j);
            }
        });
        sw.Stop();
        Console.WriteLine($"ConcurrentQueue: {sw.ElapsedMilliseconds} ms. Count: {concurrentQueue.Count}");
        // Espere que o ConcurrentQueue seja bem mais rápido quando NumTasks > 1
    }
}

Conclusão: Se você vê um lock em torno de coleções, isso geralmente é um sinal para migrar para os análogos Concurrent.

6. Nuances úteis

Impacto da contention na performance

Contention — quando muitas threads acessam o mesmo recurso ao mesmo tempo. Quanto maior a concorrência, mais esperas e pior a performance.

Coleções Concurrent são projetadas para reduzir contention: por exemplo, ConcurrentBag<T> usa armazenamentos locais por thread, e ConcurrentDictionary<TKey, TValue> usa locks segmentados (striped locking).

A chave pra performance é reduzir contention: quando possível, separe dados entre threads ou use múltiplas coleções.

Escolhendo a coleção certa pro cenário

Coleção Ordem Quando usar Quando não usar
ConcurrentQueue<T>
FIFO (First-In, First-Out) Filas de tarefas, logging, processamento assíncrono de eventos, Producer-Consumer. Se a ordem não importa, precisa de LIFO ou exige limite de tamanho com bloqueio.
ConcurrentStack<T>
LIFO (Last-In, First-Out) Histórico de operações (Undo/Redo), travessia de grafos (DFS), pools de objetos com prioridade “último inserido”. Se FIFO é crítico ou a ordem precisa ser estável.
ConcurrentBag<T>
Não garantida Pools de objetos, quando produtor e consumidor frequentemente são a mesma thread; cenários TPL onde locality importa. Se a ordem dos elementos é importante.
ConcurrentDictionary<TKey, TValue>
Não Cache, sessões de usuários, contagem de estatísticas, agregação paralela. Se você não precisa de um dicionário.
BlockingCollection<T> (por cima de ConcurrentQueue) FIFO (ou outra coleção base) Producer-Consumer com operações bloqueantes e limitação de tamanho, finalização conveniente. Se não precisa de operações bloqueantes ou limitação de tamanho.

7. Dicas de otimização

Evite chamar ToArray() frequentemente em caminhos quentes

ToArray() cria uma cópia inteira da coleção — caro em memória e tempo. Use só quando precisar de um “snapshot” e o menos possível. Para contar existe Count (lembre que é um snapshot no momento da chamada).

Cuidado ao iterar sobre coleções Concurrent

Iteradores não garantem estabilidade com modificações paralelas: você pode pular elementos ou ver uma visão inconsistente. Para uma visão estável, primeiro faça um snapshot com ToArray().

// Ruim: pode pular elementos ou ver mudanças durante a iteração
foreach (var item in myConcurrentQueue) { /* ... */ }

// Bom: iteração sobre um snapshot fixo
var snapshot = myConcurrentQueue.ToArray();
foreach (var item in snapshot) { /* ... */ }

Minimize o “tráfego” pela coleção

Agrupe tarefas/dados: menos chamadas Add/Take — menos potencial de contention. Por exemplo, em vez de 1000 mensagens separadas — um “pacote” com 1000.

Monitore fontes de concorrência

Se você observar degradação, meça onde está a maior contention. Talvez o design possa ser alterado para que threads trabalhem com dados locais ou diferentes coleções.

1
Pesquisa/teste
Coleções Concurrent, nível 58, lição 4
Indisponível
Coleções Concurrent
Coleções thread-safe
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION