Antes do .NET 4.0, se precisássemos usar a classe Dicionário em um ambiente multithread, não tínhamos escolha a não ser implementar a sincronização de threads nós mesmos para manter as threads seguras.
Muitos desenvolvedores certamente implementaram uma solução thread-safe semelhante, seja criando um tipo de dicionário thread-safe totalmente novo, ou simplesmente encapsulando um objeto Dicionário em uma classe e adicionando um mecanismo de travamento a todos os métodos, que chamamos de "Dicionário + Bloqueios".
Mas agora, temos o ConcurrentDictionary. A descrição thread-safe da documentação da classe Dicionário no MSDN afirma que, se você precisar usar uma implementação thread-safe, use o ConcurrentDictionary.
Então, agora que temos uma classe de dicionário segura para threads, não precisamos mais implementá-la nós mesmos. Ótimo, não é?
Origem do problema
Na verdade, só usei o CocurrentDictionary uma vez antes, no meu teste, para testar sua capacidade de resposta. Como ele teve bom desempenho nas provas, eu imediatamente substituí pela minha turma, fiz alguns testes e então algo deu errado.
Então, o que deu errado? Você não disse seguro para fios?
Depois de mais testes, encontrei a raiz do problema. Mas, por algum motivo, a versão 4.0 do MSDN não inclui uma descrição da assinatura do método GetOrAdd que exige passar um parâmetro de tipo delegado. Depois de olhar a versão 4.5, encontrei esta nota:
Se você chamar GetOrAdd simultaneamente em diferentes threads, o addValueFactory pode ser chamado várias vezes, mas seu par chave/valor pode não ser adicionado ao dicionário para cada chamada. Esse foi o problema que encontrei. Como não estava descrito anteriormente na documentação, precisei fazer mais testes para confirmar o problema. Claro, o problema que estou enfrentando está relacionado ao meu uso, em geral, uso o tipo dicionário para armazenar alguns dados em cache:
Esses dados demoram muito a ser criados; Esses dados só podem ser criados uma vez, porque a segunda criação lançará uma exceção, ou múltiplas criações podem levar a vazamento de recursos, etc.; Tive um problema com a segunda condição. Se ambos os threads descobrirem que um dado não existe, ele será criado uma vez, mas apenas um resultado será salvo com sucesso. E o outro?
Se o processo que você cria lançar uma exceção, você pode usar o try: Captura (não é elegante o suficiente, mas resolve o problema). Mas e se um recurso for criado e não reciclado?
Você pode dizer que um objeto é criado e será coletado no lixo se ele não for mais referenciado nele. No entanto, considere o que aconteceria se a situação descrita abaixo ocorresse:
Gerar código dinamicamente com o Emit. Usei essa abordagem em um framework Remoting e coloquei todas as implementações em um assembly que não pôde ser reciclado. Se um tipo for criado duas vezes, o segundo sempre existirá, mesmo que nunca tenha sido usado. Crie um tópico direta ou indiretamente. Por exemplo, precisamos construir um componente que use uma thread proprietária para processar mensagens assíncronas e dependa da ordem em que elas são recebidas. Quando o componente é instanciado, uma thread é criada. Quando essa instância de componente é destruída, a thread também é encerrada. Mas se deletarmos a referência ao objeto após destruir o componente, a thread não termina por algum motivo e mantém a referência ao objeto. Então, se a linha não morrer, o objeto também não será reciclado. Realize uma operação P/Invoke. Exigir que o número de tempos de fechamento para a alça recebida seja o mesmo que o número de aberturas. Com certeza, existem muitas situações semelhantes. Por exemplo, um objeto de dicionário manterá uma conexão com um serviço em um servidor remoto, que só pode ser solicitado uma vez, e se for solicitado uma segunda vez, o outro serviço pensará que algum tipo de erro ocorreu e o registrará no log. (Em uma empresa para a qual trabalhei, havia algumas penalidades legais para essa condição.) ) Então, é fácil ver que Dicionário + Bloqueios não pode ser substituído às pressas pelo ConcurrentDictionary, mesmo que a documentação diga que é seguro para threads.
Analise o problema
Ainda não entendeu?
É verdade que essa questão pode não surgir sob a abordagem Dicionário + Cadeados. Como isso depende da implementação específica, vamos analisar este exemplo simples:
No código acima, seguramos o cadeado no dicionário antes de começar a consultar o valor-chave. Se o par chave-valor especificado não existir, ele será criado diretamente. Ao mesmo tempo, como já temos um lock nesse dicionário, podemos adicionar pares chave-valor diretamente ao dicionário. Depois, solte o bloqueio do dicionário e retorne o resultado. Se duas threads estiverem consultando o mesmo valor de chave ao mesmo tempo, a primeira thread que obtiver o lock do dicionário completará a criação do objeto, e a outra thread aguardará a conclusão dessa criação e obterá o resultado do valor da chave criada após obter o lock do dicionário.
Isso é bom, não é?
Não é mesmo! Não acho que criar um objeto em paralelo assim, onde só um é usado no final, não crie o problema que descrevi.
A situação e o problema que estou tentando explicar podem nem sempre ser reprodutíveis; em um ambiente paralelo, podemos simplesmente criar dois objetos e depois descartar um. Então, como exatamente comparamos Dicionário + Bloqueios e ConcurrentDictionary?
A resposta é: depende da estratégia de uso do bloqueio e de como o dicionário é usado.
Jogo 1: Criar o mesmo objeto em paralelo
Primeiro, vamos supor que um objeto possa ser criado duas vezes, então o que acontece se dois threads criarem esse objeto ao mesmo tempo?
Em segundo lugar, quanto tempo gastamos em criações semelhantes?
Podemos simplesmente construir um exemplo onde instanciar um objeto leva 10 segundos. Quando a primeira thread cria o objeto 5 segundos depois, a segunda implementação tenta chamar o método GetOrAdd para obter o objeto, e como o objeto ainda não existe, ela também começa a criá-lo.
Nessa condição, temos 2 CPUs trabalhando em paralelo por 5 segundos, e quando a primeira thread termina de funcionar, a segunda thread ainda precisa continuar rodando por 5 segundos para completar a construção do objeto. Quando a segunda thread termina de construir o objeto, ela descobre que um objeto já existe e escolhe usar o objeto existente e descartar diretamente o objeto recém-criado.
Se a segunda thread simplesmente esperar e a segunda CPU fizer outro trabalho (rodando outras threads ou aplicações, economizando energia), ela receberá o objeto desejado após 5 segundos em vez de 10 segundos.
Então, nessas condições, Dicionário + Fechaduras vence um jogo pequeno.
Jogo 2: Visite diferentes objetos em paralelo
Não, a situação que você disse não é verdade!
Bem, o exemplo acima é um pouco peculiar, mas descreve o problema, só que esse uso é mais extremo. Então, considere o que acontece se a primeira thread estiver criando um objeto, e a segunda thread precisar acessar outro objeto-chave-valor, e esse objeto-chave-valor já existir?
No ConcurrentDictionary, o design sem trava torna as leituras muito rápidas porque não há travamento na leitura. No caso de Dicionário + Bloqueios, a operação de leitura será bloqueada mutuamente exclusiva, mesmo que seja uma chave completamente diferente, o que obviamente retardará a operação de leitura.
Dessa forma, o ConcurrentDictionary retirou um jogo.
Nota: Aqui considero que você entende vários conceitos, como Bucket/Node/Entry na classe do dicionário; caso contrário, recomenda-se ler o artigo de Ofir Makmal "Entendendo Dicionário Genérico em profundidade", que explica bem esses conceitos.
O terceiro jogo do jogo: leia mais e escreva single
O que acontece se você usar Múltiplos Leitores e Único Escritor em vez de um bloqueio total no dicionário no Dicionário + Bloqueios?
Se uma thread estiver criando um objeto e mantiver um lock atualizável até que o objeto seja criado, o lock for atualizado para um lock de escrita, então a operação de leitura pode ser realizada em paralelo.
Também podemos resolver o problema deixando uma operação de leitura ociosa por 10 segundos. Mas se houver muito mais leituras do que escritos, veremos que o ConcurrentDictionary ainda é rápido porque implementa leituras em modo sem bloqueios.
Usar o ReaderWriterLockSlim para Dicionários piora as leituras, e geralmente é recomendado usar o Bloqueio Completo para Dicionários em vez do ReaderWriterLockSlim.
Então, nessas condições, o ConcurrentDictionary venceu mais uma partida.
Nota: Já abordei as aulas YieldReaderWriterLock e YieldReaderWriterLockSlim em artigos anteriores. Ao usar esse bloqueio de leitura-escrita, a velocidade foi consideravelmente melhorada (agora evoluiu para SpinReaderWriterLockSlim) e permite que múltiplas leituras sejam executadas em paralelo com pouco ou nenhum impacto. Embora eu ainda esteja usando essa forma, um Dicionário Concorrentes sem bloqueio obviamente seria mais rápido.
Jogo 4: Adicionar múltiplos pares chave-valor
O confronto ainda não acabou.
E se tivermos múltiplos valores-chave para adicionar, e todos eles não colidirem e forem atribuídos em diferentes compartimentos?
No começo, essa pergunta era curiosa, mas fiz um teste que não encaixou direito. Usei um dicionário de tipos <int, int> e a fábrica de construção do objeto retornava um resultado negativo diretamente como chave.
Eu esperava que o ConcurrentDictionary fosse o mais rápido, mas acabou sendo o mais lento. Dicionário + Travas, por outro lado, funciona mais rápido. Por que isso?
Isso ocorre porque o ConcurrentDictionary aloca nós e os coloca em diferentes buckets, o que é otimizado para atender ao design sem bloqueio para operações de leitura. No entanto, ao adicionar itens-chave-valor, o processo de criação de um nó se torna caro.
Mesmo em condições paralelas, alocar um bloqueio de nó ainda consome mais tempo do que usar um bloqueio completo.
Então, Dicionário + Fechaduras vence este jogo.
Jogando o quinto jogo: A frequência das operações de leitura é maior
Francamente, se tivéssemos um delegado que pudesse instanciar objetos rapidamente, não precisaríamos de um Dicionário. Podemos ligar diretamente para o delegado para pegar o objeto, certo?
Na verdade, a resposta também é que depende da situação.
Imagine que o tipo de chave é uma string e contém mapas de caminho para várias páginas no servidor web, e o valor correspondente é um tipo de objeto que contém o registro dos usuários atuais acessando a página e o número de todas as visitas desde o início do servidor.
Criar um objeto assim é quase instantâneo. E depois disso, você não precisa criar um novo objeto, apenas mudar os valores salvos nele. Assim, é possível permitir a criação de um caminho duas vezes até que apenas uma instância seja usada. No entanto, como o ConcurrentDictionary aloca recursos de nós mais lentamente, usar Dicionário + Bloqueios resultará em tempos de criação mais rápidos.
Então, com esse exemplo muito especial, também vemos que Dicionário + Bloqueios tem melhor desempenho nessa condição, levando menos tempo.
Embora a alocação de nós no ConcurrentDictionary seja mais lenta, não tentei colocar 100 milhões de itens de dados para testar o tempo. Porque isso obviamente leva muito tempo.
Mas, na maioria dos casos, uma vez que um item de dados é criado, ele é sempre lido. Como o conteúdo do item de dados muda é outra questão. Então não importa quantos milissegundos a mais leva para criar um item de dados, porque as leituras são mais rápidas (apenas alguns milissegundos mais rápidas), mas as leituras acontecem com mais frequência.
Então, o ConcurrentDictionary venceu o jogo.
Jogo 6: Crie objetos que consomem diferentes tempos
O que acontece se o tempo necessário para criar diferentes itens de dados variar?
Crie múltiplos itens de dados que consumam diferentes tempos e adicione-os ao dicionário em paralelo. Esse é o ponto mais forte do ConcurrentDictionary.
O ConcurrentDictionary usa vários mecanismos de bloqueio diferentes para permitir que itens de dados sejam adicionados simultaneamente, mas lógica como decidir qual bloqueio usar, solicitar que um bloqueio mude o tamanho do balde, etc., não ajuda. A velocidade com que os itens de dados são colocados em um balde é rápida como uma máquina. O que realmente faz o ConcurrentDictionary vencer é sua capacidade de criar objetos em paralelo.
No entanto, podemos fazer a mesma coisa. Se não nos importarmos se estamos criando objetos em paralelo, ou se alguns deles foram descartados, podemos adicionar um bloqueio para detectar se o item de dados já existe, então soltar o bloqueio, criar o item de dados, pressioná-lo para obter o bloqueio, verificar novamente se o item de dados existe, e se não existir, adicionar o item de dados. O código pode parecer algo assim:
* Note que uso um dicionário de tipos <int, int>.
Na estrutura simples acima, Dictionary + Locks tem desempenho quase tão bom quanto o ConcurrentDictionary ao criar e adicionar itens de dados em condições paralelas. Mas também há o mesmo problema, onde alguns valores podem ser gerados, mas nunca usados.
conclusão
Então, existe uma conclusão?
Neste momento, ainda existem algumas:
Todas as aulas de dicionário são muito rápidas. Mesmo tendo criado milhões de dados, ainda é rápido. Normalmente, criamos apenas um pequeno número de itens de dados, e há alguns intervalos de tempo entre as leituras, então geralmente não percebemos o tempo extra de leitura dos itens de dados. Se o mesmo objeto não puder ser criado duas vezes, não use o ConcurrentDictionary. Se você realmente se preocupa com desempenho, Dicionário + Bloqueios ainda pode ser uma boa solução. Um fator importante é o número de itens de dados adicionados e removidos. Mas se houver muitas operações de leitura, ele é mais lento que o ConcurrentDictionary. Embora eu não tenha apresentado, na verdade há mais liberdade para usar o esquema Dicionário + Fechaduras. Por exemplo, você pode travar uma vez, adicionar múltiplos itens de dados, excluir vários itens de dados ou consultar várias vezes, etc., e então liberar o bloqueio. Em geral, evite usar o ReaderWriterLockSlim se houver muito mais leituras do que escritas. Os tipos de dicionário já são muito mais rápidos do que obter um bloqueio de leitura em um bloqueio de leitura-escrita. Claro, isso também depende do tempo gasto para criar um objeto em uma fechadura. Então, acho que os exemplos dados são um pouco extremos, mas mostram que usar o ConcurrentDictionary nem sempre é a melhor solução.
Sinta a diferença
Escrevi este artigo com a intenção de buscar uma solução melhor.
Já estou tentando entender melhor como funciona uma disciplina específica de dicionário (agora sinto que estou muito claro).
Pode-se argumentar que Bucket e Node no ConcurrentDictionary são muito simples. Fiz algo parecido quando tentei criar uma aula de dicionário. A classe regular do Dicionário pode parecer mais simples, mas, na verdade, é mais complexa.
No ConcurrentDictionary, cada nó é uma classe completa. Na classe Dicionário, Node é implementado usando um tipo de valor, e todos os nós são mantidos em um array enorme, enquanto Bucket é usado para indexar no array. Também é usado no lugar da simples referência de um Node ao seu próximo Node (afinal, como um Node do tipo struct, não pode conter um membro Node do tipo struct).
Ao adicionar e remover um dicionário, a classe Dicionário não pode simplesmente criar um novo nó; ela deve verificar se há um índice marcando um nó que foi deletado e então reutilizá-lo. Ou "Contagem" é usada para obter a posição do novo Nó no array. Na verdade, quando o array está cheio, a classe Dicionário força uma mudança de tamanho.
Para o ConcurrentDictionary, um Nó pode ser considerado um novo objeto. Remover um nó é simplesmente remover sua referência. Adicionar um novo Node pode simplesmente criar uma nova instância de Node. Mudar o tamanho serve apenas para evitar conflitos, mas não é obrigatório.
Então, se a classe Dicionário está usando propositalmente algoritmos mais complexos para lidar com ela, como o ConcurrentDictionary garantirá que ela tenha melhor desempenho em um ambiente multithreaded?
A verdade é: colocar todos os nós em um único array é a forma mais rápida de alocar e ler, mesmo que precisemos de outro array para acompanhar onde encontrar esses dados. Então parece que ter o mesmo número de buckets vai usar mais memória, mas os novos itens de dados não precisam ser realocados, não são necessárias novas sincronizações de objetos, e não ocorre uma nova coleta de lixo. Porque tudo já está no lugar.
No entanto, substituir conteúdo em um Nó não é uma operação atômica, o que é um dos fatores que tornam sua thread insegura. Como todos os nós são objetos, um nó é inicialmente criado e, em seguida, uma referência separada é atualizada para apontá-lo (operação atômica aqui). Assim, a thread de leitura pode ler o conteúdo do dicionário sem bloqueio, e a leitura deve ser um dos valores antigo e novo, e não há chance de ler um valor incompleto.
Então, a verdade é: se você não precisa de um bloqueio, a classe Dicionário é mais rápida nas leituras, porque é o bloqueio que desacelera a leitura.
Este artigo é traduzido do artigo de Paulo Zemek, "Dicionário + Travamento versus Dicionário Concorrente" no CodeProject, e algumas afirmações mudarão por razões de compreensão.
|