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:
- Ler o valor atual da variável.
- Calcular o novo valor com base no valor lido.
- 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 |
|---|---|---|---|
|
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. |
|
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. |
|
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. |
|
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.
GO TO FULL VERSION