C# - Apresentando ConcurrentDictionary


 Neste artigo veremos como funciona o ConcurrentDictionary na linguagem C#.
 
No mundo da programação multithread, é crucial garantir a segurança da thread ao acessar recursos compartilhados.

A linguagem C# oferece uma variedade de coleções simultâneas para facilitar essa tarefa, e uma das mais versáteis entre elas é a ConcurrentDictionary.

O que é ConcurrentDictionary ?

A classe ConcurrentDictionary pertence à biblioteca System.Collections.Concurrent projetada para ser usada em cenários multithread que fornece uma implementação segura para operações concorrentes em dicionários. Ela foi projetado para ser usado em cenários onde vários threads acessam e modificam um dicionário simultaneamente.

A classe ConcurrentDictionary usa técnicas de bloqueio mais granulares, conhecidas como "striping", para permitir que várias operações ocorram simultaneamente em seções diferentes do dicionário, melhorando o desempenho em ambientes concorrentes.

Principais Métodos e Propriedades

Esta classe fornece uma variedade de métodos e propriedades úteis. Alguns dos principais são:

- AddOrUpdate :   Adiciona uma chave e um valor ao dicionário ou atualiza o valor associado a uma chave existente.

   concurrentDictionary.AddOrUpdate("Chave", valor, (chave, valorExistente) => novoValor);

- GetOrAdd :   Obtém o valor associado a uma chave existente no dicionário ou adiciona uma nova chave e valor, se a chave não existir.

   var valor = concurrentDictionary.GetOrAdd("Chave", valorPadrao);

- TryGetValue :   Tenta obter o valor associado a uma chave, retornando verdadeiro se a operação foi bem-sucedida.

if (concurrentDictionary.TryGetValue("Chave", out var valor))
{
    // Faça algo com o valor
}

- Count :  Propriedade que retorna o número de pares chave-valor no dicionário.

   int numeroDeElementos = concurrentDictionary.Count;

- Keys e Value : Propriedades que retornam coleções de chaves e valores, respectivamente.

var chaves = concurrentDictionary.Keys;
var valores = concurrentDictionary.Values;

Assim, a classe ConcurrentDictionary é útil em situações em que várias threads precisam acessar ou modificar um dicionário simultaneamente, garantindo um acesso seguro e eficiente.  Sendo particularmente útil em cenários de programação concorrente, como em aplicações multi-threaded ou em situações em que a concorrência é necessária para otimizar o desempenho.

Capacidade Inicial

Ao inicializar um ConcurrentDictionary, você pode especificar sua capacidade inicial que refere-se ao número inicial de slots que o dicionário pode conter.

Especificar uma capacidade inicial pode ser benéfico se você tiver uma estimativa do número de itens que o dicionário conterá. Isso pode ajudar a reduzir o número de redimensionamentos, o que pode ser caro em termos de desempenho. A capacidade inicial padrão é 31 (um número primo)

using System.Collections.Concurrent;

int capacidadeInicial = 11;

int nívelConcorrencia = Environment.ProcessorCount * 2;

var concurrentDictionary = new
    ConcurrentDictionary<
int, string>(nívelConcorrencia, capacidadeInicial);

Exemplo Prático

Imagine que você está construindo um serviço que calcula o fatorial de números. O cálculo fatorial pode ser demorado para números grandes. Para otimizar isso, você pode armazenar em cache os resultados de fatoriais calculados anteriormente usando um ConcurrentDictionary.

Vamos criar um projeto Console no .NET 8 chamado Cshp_ConcurrentDictionary e definir o código abaixo na classe Program:

using System.Collections.Concurrent;

var service = new FactorialService();

Console.WriteLine("Calculando 5! ... ");

//calcula o resultado e põe no cache
Console.WriteLine(service.CalculaFatorial(5));
// usa o resultado
Console.WriteLine("\nUsando o resultado de 5! ... ");
Console.WriteLine(service.CalculaFatorial(5));

Console.WriteLine("\nCalculando 7! ... ");
Console.WriteLine(service.CalculaFatorial(7));

Console.ReadLine();

public class FactorialService
{
 
private ConcurrentDictionary<int, long> _cache = new ConcurrentDictionary<int, long>();
 
public long CalculaFatorial(int numero)
  {
    
if (numero < 0)
     {
      
throw new ArgumentException("Número não pode ser negativo.");
     }

     // Se o resultadoado já esta no cache retorna
    
if (_cache.TryGetValue(numero, out long cachedResult))
     {
         Console.WriteLine(
$"Cache encontrado para : {numero}!");
        
return cachedResult;
      }
     
// Calcula o fatorial
     
long resultado = 1;
     
for (int i = 1; i <= numero; i++)
      {
         resultado *= i;
      }
     
// põe o resultadoado no cache
      _cache[numero] = resultado;
     
return resultado;
   }
}

Resultado:

Criamos uma classe FactorialService que possui um ConcurrentDictionary chamado _cache para armazenar os resultados de fatoriais calculados anteriormente.

O método CalculaFactorial primeiro verifica se o resultado do número fornecido já está no cache. Se for, ele retorna o resultado armazenado em cache.

Se o resultado não estiver no cache, ele calcula o fatorial, armazena o resultado em cache e depois o retorna.

Essa abordagem garante que os resultados de cálculos caros sejam armazenados em cache e reutilizados, melhorando o desempenho do serviço. Além disso, o uso de ConcurrentDictionary garante que o mecanismo de cache seja seguro para threads, permitindo que vários threads calculem e armazenem resultados em cache simultaneamente sem problemas.

Nem tudo são flores

Acontece que nem tudo são flores ao usar este recurso e você deverá atentar para os seguintes aspectos que podem estar envolvidos dependendo do cenário de uso :

Sobrecarga de simultaneidade: ConcurrentDictionary foi projetado para segurança de thread. Para conseguir isso, ele emprega bloqueio refinado e outros mecanismos de simultaneidade. Esses mecanismos podem introduzir alguma sobrecarga em comparação com o Dicionário não thread-safe. Em cenários com alta contenção (muitos threads tentando acessar o dicionário simultaneamente), essa sobrecarga pode ser perceptível.

Sobrecarga de memória: ConcurrentDictionary pode usar mais memória do que um dicionário normal devido às suas estruturas internas projetadas para lidar com a simultaneidade.

Cenários de leitura pesada: Em cenários onde há predominantemente operações de leitura com gravações mínimas, o ConcurrentDictionary é otimizado para ter um desempenho muito bom. Muitas vezes, as leituras podem ocorrer totalmente sem bloqueios, tornando-as muito rápidas.

Redimensionamento de bucket: Como qualquer coleção baseada em hash, o desempenho pode ser prejudicado se houver muitas colisões. No entanto, ConcurrentDictionary lida com o redimensionamento de seus intervalos para distribuir as chaves e reduzir colisões. Esse redimensionamento é seguro para threads, mas pode introduzir alguma sobrecarga durante as operações de gravação, quando ocorre.

Obs: Um "bucket" refere-se a uma estrutura de dados que armazena elementos com chaves hash iguais ou colidentes. O redimensionamento de bucket ocorre quando a tabela de dispersão precisa ser redimensionada para acomodar mais elementos ou para reduzir a carga (load) da tabela.

Pegue o código do projeto aqui:  Cshp_ConcurrentDictionary.zip

"Ninguém vos engane com palavras vãs; porque por estas coisas vem a ira de Deus sobre os filhos da desobediência. Portanto, não sejais seus companheiros. Porque noutro tempo éreis trevas, mas agora sois luz no Senhor; andai como filhos da luz"
Efésios 5:6-8

Referências:


José Carlos Macoratti