
A plataforma .NET fornece muitas primitivas de sincronização pré-criadas e coleções seguras para threads. Se você precisar implementar, por exemplo, um cache seguro de thread ou uma fila de solicitações ao desenvolver um aplicativo, essas soluções prontas geralmente são usadas, às vezes várias ao mesmo tempo. Em alguns casos, isso leva a problemas de desempenho: uma longa espera por bloqueios, consumo excessivo de memória e longa coleta de lixo.
Esses problemas podem ser resolvidos se levarmos em conta que as soluções padrão são generalizadas - elas podem ter uma sobrecarga em nossos cenários que é redundante. Assim, você pode escrever, por exemplo, sua própria coleção eficaz de thread-safe para um caso específico.
Sob a cena, há um vídeo e uma transcrição do meu relatório da conferência
DotNext , onde analiso vários exemplos em que o uso de ferramentas da biblioteca .NET padrão (Task.Delay, SemaphoreSlim, ConcurrentDictionary) levou a quedas de desempenho e proponho soluções personalizadas para tarefas específicas e desprovidas de essas deficiências.
Na época do relatório, ele trabalhava em Kontur. A Kontur desenvolve vários aplicativos de negócios, e a equipe em que trabalhei lida com infraestrutura e desenvolve vários serviços de suporte e bibliotecas para ajudar desenvolvedores de outras equipes a criar serviços de produtos.
A equipe de infraestrutura constrói seu data warehouse, um sistema de hospedagem de aplicativos para Windows e várias bibliotecas para o desenvolvimento de microsserviços. Nossos aplicativos são baseados em uma arquitetura de microsserviço - todos os serviços interagem entre si pela rede e, é claro, eles usam bastante código assíncrono e multithread. Alguns desses aplicativos são bastante críticos para o desempenho, pois precisam ser capazes de lidar com muitas solicitações.
Sobre o que vamos falar hoje?
- Multithreading e assincronia no .NET;
- Primitivas e coleções de sincronização de enchimento;
- O que fazer se as abordagens padrão não puderem lidar com a carga?
Vamos analisar alguns recursos do trabalho com código multithread e assíncrono no .NET. Vamos dar uma olhada em algumas primitivas de sincronização e coleções simultâneas, ver como elas são organizadas. Discutiremos o que fazer se não houver desempenho suficiente, se as classes padrão não puderem lidar com a carga e se algo pode ser feito nessa situação.
Vou contar quatro histórias que aconteceram em nosso local de produção.
Histórico 1: Task.Delay & TimerQueue
Esta história já é bastante conhecida, inclusive sobre ela no DotNext anterior. No entanto, obteve uma sequência bastante interessante, então eu a adicionei. Então qual é o objetivo?
1.1 Votação e sondagem longa
O servidor executa operações longas, o cliente espera por elas.
Pesquisa: o cliente pergunta periodicamente ao servidor sobre o resultado.
Pesquisa longa: o cliente envia uma solicitação com um tempo limite longo e o servidor responde quando a operação é concluída.
Vantagens:
- Menos tráfego
- O cliente aprende sobre o resultado mais rapidamente
Imagine que temos um servidor que pode lidar com algumas solicitações longas, por exemplo, um aplicativo que converte arquivos XML em PDF, e há clientes que executam essas tarefas para processamento e desejam aguardar o resultado de forma assíncrona. Como essa expectativa pode ser realizada?
A primeira maneira é
pesquisar . O cliente inicia a tarefa no servidor e verifica periodicamente o status dessa tarefa, enquanto o servidor retorna o status da tarefa ("concluído" / "falhou" / "concluído com erro"). O cliente envia periodicamente solicitações até o resultado aparecer.
A segunda maneira é a
pesquisa longa . A diferença aqui é que o cliente envia solicitações com tempos limite longos. O servidor, recebendo essa solicitação, não informará imediatamente que a tarefa não foi concluída, mas tentará esperar um pouco para que o resultado apareça.
Então, qual é a vantagem da pesquisa longa sobre a pesquisa regular? Em primeiro lugar, menos tráfego é gerado. Fazemos menos solicitações de rede - menos tráfego está sendo perseguido pela rede. Além disso, o cliente poderá descobrir o resultado mais rapidamente do que com a pesquisa regular, porque ele não precisa esperar pelo intervalo entre várias solicitações de pesquisa. O que queremos obter é compreensível. Como vamos implementar isso no código?
Tarefa: tempo limite
Queremos esperar a tarefa com um tempo limite
aguarde SendAsync ();
Por exemplo, temos uma tarefa que envia uma solicitação ao servidor e queremos aguardar o resultado com um tempo limite, ou seja, retornaremos o resultado dessa tarefa ou enviaremos algum tipo de erro. O código C # ficará assim:
var sendTask = SendAsync(); var delayTask = Task.Delay(timeout); var task = await Task.WhenAny(sendTask, delayTask); if (task == delayTask) return Timeout;
Esse código inicia nossa tarefa, cujo resultado queremos aguardar, e Task.Delay. Em seguida, usando Task.WhenAny, estamos aguardando nossa Task ou Task.Delay. Se o Task.Delay for executado primeiro, o tempo acabou e temos um tempo limite, devemos retornar um erro.
Este código, é claro, não é perfeito e pode ser aprimorado. Por exemplo, não faria mal cancelar o Task.Delay se o SendAsync retornasse mais cedo, mas isso não é muito interessante para nós agora. A conclusão é que, se escrevermos esse código e aplicá-lo para pesquisas longas com tempos limite longos, teremos alguns problemas de desempenho.
1.2 Problemas com pesquisas longas
- Timeouts grandes
- Muitas consultas simultâneas
- => Alta utilização da CPU
Nesse caso, o problema é o alto consumo de recursos do processador. Pode acontecer que o processador esteja totalmente carregado em 100% e o aplicativo geralmente pare de funcionar. Parece que não consumimos recursos do processador: realizamos algumas operações assíncronas, aguardamos uma resposta do servidor e o processador ainda está carregado conosco.
Quando enfrentamos essa situação, removemos um despejo de memória do nosso aplicativo:
~*e!clrstack System.Threading.Monitor.Enter(System.Object) System.Threading.TimerQueueTimer.Change(…) System.Threading.Timer.TimerSetup(…) System.Threading.Timer..ctor(…) System.Threading.Tasks.Task.Delay(…)
Para analisar o despejo, usamos a ferramenta WinDbg. Nós inserimos um comando que mostra rastreamentos de pilha de todos os threads gerenciados e vimos esse resultado. Temos muitos threads em processo que aguardam algum bloqueio. O método Monitor.Enter é o que a construção de bloqueio em C # expande. Esse bloqueio é capturado dentro de classes chamadas Timer e TimerQueueTimer. No Timer, viemos do Task.Delay quando tentamos criá-los. O que é isso? Quando o Task.Delay é iniciado, o bloqueio dentro do TimerQueue é capturado.
1.3 Comboio de bloqueio
- Muitos threads tentam bloquear um bloqueio
- Sob o bloqueio, pouco código é executado
- O tempo é gasto na sincronização do encadeamento, não na execução do código.
- Blocos de segmentos estão bloqueados - eles não são infinitos
Tivemos um comboio de trava no aplicativo. Muitos threads tentam capturar o mesmo bloqueio. Sob esse bloqueio, um pouco de código é executado. Os recursos do processador aqui não são gastos no próprio código do aplicativo, mas em operações para sincronizar threads entre si nesse bloqueio. Também vale a pena notar um recurso relacionado ao .NET: os threads que participam do comboio de bloqueio são threads do pool de threads.
Portanto, se os encadeamentos do conjunto de encadeamentos estiverem bloqueados, eles poderão terminar - o número de encadeamentos no conjunto de encadeamentos é limitado. Pode ser configurado, mas ainda há um limite superior. Depois de atingido, todos os threads do conjunto de threads participarão do comboio de bloqueio e qualquer código que envolva o conjunto de threads deixará de ser executado no aplicativo. Isso piora bastante a situação.
1.4 TimerQueue
- Gerencia temporizadores em um aplicativo .NET.
- Os temporizadores são usados em:
- Task.Delay
- CancellationTocken.CancelAfter
- HttpClient
TimerQueue é uma classe que gerencia todos os cronômetros em um aplicativo .NET. Se você programou uma vez no WinForms, pode ter criado temporizadores manualmente. Para quem não sabe o que são os cronômetros: eles são usados no Task.Delay (esse é apenas o nosso caso), eles também são usados dentro do CancellationToken, no método CancelAfter. Ou seja, substituir Task.Delay por CancellationToken.CancelAfter não nos ajudaria de forma alguma. Além disso, os cronômetros são usados em várias classes internas do .NET, por exemplo, no HttpClient.
Até onde eu sei, algumas implementações de manipuladores HttpClient possuem temporizadores. Mesmo se você não os usar explicitamente, não inicie o Task.Delay, provavelmente, você ainda os usa de qualquer maneira.
Agora vamos ver como o TimerQueue está organizado por dentro.
- Estado global (por domínio de aplicativo):
- Lista vinculada dupla de TimerQueueTimer
- Bloquear objeto - Retornos de chamada do temporizador de rotina
- Temporizadores não ordenados por tempo de resposta
- Adicionando um timer: O (1) + bloqueio
- Remoção do temporizador: O (1) + bloqueio
- Temporizadores de início: O (N) + bloqueio
Dentro de TimerQueue, existe um estado global, é uma lista duplamente vinculada de objetos do tipo TimerQueueTimer. TimerQueueTimer contém um link para outro TimerQueueTimer, vizinho em uma lista vinculada, mas também contém a hora do timer e o retorno de chamada, que serão chamados quando o timer for disparado. Essa lista duplamente vinculada é protegida por um objeto de bloqueio, exatamente aquele no qual o comboio de bloqueio aconteceu em nosso aplicativo. Também dentro do TimerQueue, há uma Rotina que lança retornos de chamada vinculados aos nossos timers.
Os temporizadores não são ordenados pelo tempo de resposta, toda a estrutura é otimizada para adicionar / remover novos temporizadores. Quando o Rotina inicia, ele percorre toda a lista duplamente vinculada, seleciona os cronômetros que devem funcionar e os chama de volta.
A complexidade da operação aqui é tal. Adicionar e remover um cronômetro ocorre O por unidade e o início dos cronômetros ocorre por linha. Além disso, se tudo é aceitável com a complexidade algorítmica, há um problema: todas essas operações capturam o bloqueio, o que não é muito bom.
Que situação pode acontecer? Temos muitos timers acumulados no TimerQueue; portanto, quando o Rotine inicia, ele bloqueia sua longa operação linear; naquele momento, aqueles que tentam iniciar ou remover timers do TimerQueue não podem fazer nada a respeito. Por esse motivo, o comboio de trava ocorre. Este problema foi corrigido no .NET Core.
Reduzir a contenção de bloqueio do timer (coreclr # 14527)
- Fragmento de bloqueio
- Environment.ProcessorCount TimerQueue's TimerQueueTimer - Filas separadas para temporizadores de curta / longa duração
- Temporizador curto: tempo <= 1/3 segundo
https://github.com/dotnet/coreclr/issues/14462
https://github.com/dotnet/coreclr/pull/14527
Como foi consertado? Eles invadiram o TimerQueue: em vez de um TimerQueue, que era estático para todo o AppDomain, para todo o aplicativo, vários TimerQueue foram feitos. Quando os encadeamentos chegam lá e tentam iniciar seus cronômetros, esses cronômetros caem em um TimerQueue aleatório e os encadeamentos têm menos chance de colidir em um bloqueio.
Também no .NET Core aplicamos algumas otimizações. Os cronômetros foram divididos em TimerQueue de vida longa e de curta duração, agora são usados para eles. O temporizador de curta duração é selecionado para ser menor que 1/3 de segundo. Não sei por que essa constante foi escolhida. No .NET Core, não conseguimos detectar problemas com os cronômetros.
https://github.com/Microsoft/dotnet-framework-early-access/blob/master/release-notes/NET48/dotnet-48-changes.mdhttps://github.com/dotnet/coreclr/labels/netfx-port-considerEsta correção foi portada para o .NET Framework, versão 4.8. A tag netfx-port-consider é indicada no link acima, se você for ao repositório .NET Core, CoreCLR, CoreFX, poderá procurar esse problema que será portado para o .NET Framework, agora existem cerca de cinquenta deles. Ou seja, o código-fonte aberto .NET ajudou muito, alguns bugs foram corrigidos. Você pode ler o changelog .NET Framework 4.8: muitos erros foram corrigidos, muito mais do que em outras versões do .NET. Curiosamente, essa correção está desativada por padrão no .NET Framework 4.8. Ele está incluído no arquivo inteiro que você conhece chamado App.config
A configuração no App.config que habilita essa correção é chamada UseNetCoreTimer. Antes do lançamento do .NET Framework 4.8, para que nosso aplicativo funcionasse e não entrasse em um comboio de trava, você precisava usar sua implementação do Task.Delay. Nele, tentamos usar um heap binário para entender com mais eficiência quais timers devem ser chamados agora.
1.5 Task.Delay: implementação nativa
- Binaryheap
- Sharding
- Ajudou, mas não em todos os casos
O uso de um heap binário permite otimizar a Rotina, que chama retornos de chamada, mas piora o tempo necessário para remover um cronômetro arbitrário da fila - para isso, é necessário reconstruir o heap. É mais provável que o .NET use uma lista duplamente vinculada. Obviamente, apenas o uso de uma pilha binária não nos ajudaria aqui, também tivemos que trabalhar com o TimerQueue. Essa solução funcionou por um tempo, mas ainda assim caiu novamente no bloqueio, devido ao fato de que os timers são usados não apenas onde são executados explicitamente no código, mas também em bibliotecas de terceiros e no código .NET. Para corrigir completamente esse problema, você deve atualizar para o .NET Framework versão 4.8 e habilitar a correção dos desenvolvedores do .NET.
1.6 Tarefa. Atraso: conclusões
- Armadilhas em todos os lugares - mesmo nas coisas mais usadas
- Faça testes de estresse
- Mude para o Core, obtenha primeiro correções de bugs (e novos bugs) :)
Quais são as conclusões de toda essa história? Em primeiro lugar, as armadilhas podem estar localizadas em qualquer lugar, mesmo nas classes que você usa todos os dias, sem pensar, por exemplo, na mesma tarefa, Task.Delay.
Eu recomendo realizar testes de estresse de suas propostas. Esse problema acabamos de identificar na fase de teste de carga. Em seguida, filmamos várias vezes na produção em outras aplicações, mas, no entanto, o teste de estresse nos ajudou a adiar o tempo antes de encontrarmos esse problema na realidade.
Alterne para o .NET Core - você será o primeiro a receber correções de bugs (e novos bugs). Onde sem novos bugs?
A história sobre os temporizadores acabou e passamos para a próxima.
História 2: SemaphoreSlim
A história a seguir é sobre o conhecido SemaphoreSlim.
2.1 Limitação do servidor
- É necessário limitar o número de solicitações processadas simultaneamente no servidor
Queríamos implementar a otimização no servidor. O que é isso Você provavelmente conhece a limitação da CPU: quando o processador superaquece, diminui sua frequência para esfriar, e isso limita seu desempenho. Então está aqui. Sabemos que nosso servidor pode processar solicitações de N em paralelo e não cair. O que queremos fazer? Limite o número de solicitações processadas simultaneamente a essa constante e faça com que, se houver mais solicitações, elas enfileirem e aguardem até que as solicitações que vieram anteriormente sejam executadas. Como esse problema pode ser resolvido? É necessário usar algum tipo de primitiva de sincronização.
O semáforo é uma primitiva de sincronização na qual você pode esperar N vezes, após o qual quem chega ao N + primeiro e assim por diante o aguardará até que aqueles que entraram nele lançem o Semaphore. Acontece algo assim: dois segmentos de execução, dois trabalhadores ficaram sob o Semáforo, o resto ficou na fila.

É claro que o Semaphore não é muito adequado para nós, é no .NET síncrono, então pegamos o SemaphoreSlim e escrevemos este código:
var semaphore = new SemaphoreSlim(N); … await semaphore.WaitAsync(); await HandleRequestAsync(request); semaphore.Release();
Criamos o SemaphoreSlim, espere, sob o Semaphore processamos sua solicitação e depois lançamos o Semaphore. Parece que essa é uma implementação ideal da otimização do servidor e não pode mais ser melhor. Mas tudo é muito mais complicado.
2.2 Limitação do servidor: complicação
- Processando solicitações na ordem LIFO
- SemaphoreSlim
- Pilha simultânea
- TaskCompletionSource
Esquecemos um pouco da lógica de negócios. Os pedidos que chegam à limitação são pedidos HTTP reais. Como regra, eles têm algum tempo limite, definido por quem enviou essa solicitação automaticamente, ou um tempo limite do usuário que pressiona F5 após algum tempo. Portanto, se você processar solicitações em uma ordem da fila, como um semáforo regular, primeiro as solicitações da fila que atingiram o tempo limite já poderão ser processadas. Se você trabalha em ordem de pilha - processe primeiro todas as solicitações que vieram na última, esse problema não surgirá.
Além do SemaphoreSlim, tivemos que usar o ConcurrentStack, TaskCompletionSource, para envolver muito código em torno de tudo isso, para que tudo funcionasse na ordem que precisávamos. TaskCompletionSource é uma coisa semelhante a CancellationTokenSource, mas não para CancellationToken, mas para Task. Você pode criar um TaskCompletionSource, retirá-lo, distribuí-lo e informar ao TaskCompletionSource que você precisa definir o resultado dessa tarefa, e quem está esperando por essa tarefa descobrirá esse resultado.
Todos nós o implementamos. O código é horrível. e, pior de tudo, acabou sendo inoperante.
Alguns meses após o início de seu uso em um aplicativo bastante carregado, encontramos um problema. Da mesma forma que no caso anterior, o consumo da CPU aumentou para 100%. Fizemos o mesmo, removemos o despejo, o analisamos no WinDbg e novamente encontramos o comboio de trava.

Desta vez, o comboio de bloqueio ocorreu dentro de SemaphoreSlim.WaitAsync e SemaphoreSlim.Release. Verificou-se que existe um bloqueio dentro do SemaphoreSlim, que não é livre de bloqueio. Isso acabou sendo uma desvantagem bastante séria para nós.

Dentro do SemaphoreSlim, há um estado interno (um contador de quantos trabalhadores ainda podem passar por ele) e uma lista duplamente vinculada daqueles que estão esperando nesse semáforo. As idéias aqui são as mesmas: você pode esperar neste semáforo, você pode cancelar sua expectativa - para sair desta fila. Há uma fechadura que acabou de arruinar nossas vidas.
Decidimos: com todo o código terrível que tivemos que escrever.

Vamos escrever nosso Semáforo, que será imediatamente livre de bloqueios e que funcionará imediatamente em ordem de pilha. Cancelar a espera não é importante para nós.

Defina esta condição. Aqui será o número currentCount - este é o número de lugares restantes no Semáforo. Se não houver vagas no Semáforo, esse número será negativo e mostrará quantos trabalhadores estão na fila. Também haverá um ConcurrentStack, consistindo em TaskCompletionSource'ov - essa é apenas uma pilha de waiter'ov da qual eles serão extraídos, se necessário. Vamos escrever o método WaitAsync.
var decrementedCount = Interlocked.Decrement(ref currentCount); if (decrementedCount >= 0) return Task.CompletedTask; var waiter = new TaskCompletionSource<bool>(); waiters.Push(waiter); return waiter.Task;
Primeiro, diminuímos o balcão, ocupamos um lugar no semáforo por conta própria, se tivéssemos lugares livres, e depois dizemos: “É isso aí, você foi para o semáforo”.
Se não houver lugares no Semáforo, criamos um TaskCompletionSource, jogamos na pilha de waiter'ov e devolvemos a Tarefa para o mundo externo. Quando chegar a hora, esta tarefa funcionará, e o trabalhador poderá continuar seu trabalho e ficará sob o Semáforo.
Agora vamos escrever o método Release.
var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { if (waiters.TryPop(out var waiter)) waiter.TrySetResult(true); }
O método Release é o seguinte:
- Um assento grátis no semáforo
- Incrementar currentCount
Se pudermos dizer por currentCount se há garçom dentro da pilha sobre o qual precisamos sinalizar, puxamos esse garçom para fora da pilha e sinal. Aqui o garçom é um TaskCompletionSource. Pergunta para este código: parece lógico, mas funciona? Que problemas existem? Há uma nuance relacionada a onde a continuação e o TaskCompletionSource são lançados.

Considere este código. Criamos um TaskCompletionSource e lançamos duas tarefas. A primeira tarefa exibe uma unidade, define o resultado como TaskCompletionSource e, em seguida, exibe um empate no console. A segunda tarefa aguarda este TaskCompletionSource, em sua tarefa e, em seguida, bloqueia seu encadeamento para sempre do pool de encadeamentos.
O que vai acontecer aqui? A tarefa 2 na compilação será dividida em dois métodos, o segundo dos quais é uma continuação que contém Thread.Sleep. Após definir o resultado do TaskCompletionSource, essa continuação será executada no mesmo encadeamento em que a primeira tarefa foi executada. Consequentemente, o fluxo da primeira tarefa será bloqueado para sempre e o empate no console não será mais impresso.
Curiosamente, tentei alterar esse código e, se removi a saída da unidade do console, a continuação foi iniciada em outro encadeamento do conjunto de encadeamentos e o empate foi impresso. Em quais casos a continuação será executada no mesmo encadeamento e em que - chegará ao pool de encadeamentos - uma pergunta para os leitores.
var tcs = new TaskCompletionSource<bool>( TaskCreationOptions.RunContinuationsAsynchronously); Task.Run(() => tcs.TrySetResult(true));
Para resolver esse problema, podemos criar um TaskCompletionSource com o sinalizador RunContinuationsAsynchronously correspondente ou chamar o método TrySetResult em Task.Run/ThreadPool.QueueUserWorkItem para que não seja executado em nosso thread. Se for executado em nosso segmento, podemos ter efeitos colaterais indesejados. Além disso, há um segundo problema, vamos abordar mais detalhadamente.

Veja os métodos WaitAsync e Release e tente encontrar outro problema no método Release.
Muito provavelmente, encontrá-la simplesmente impossível. Há uma corrida aqui.

Isso se deve ao fato de que, no método WaitAsync, a alteração de estado não é atômica. Primeiro diminuímos o balcão e só depois empurramos o garçom para a pilha. Se acontecer que o Release seja executado entre decremento e push, ele poderá sair para que não puxe nada da pilha. Isso deve ser levado em consideração e, no método Release, aguarde o garçom aparecer na pilha.
var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { Waiter waiter; var spinner = new SpinWait(); while (!waiter.TryPop(out waiter)) spinner.SpinOnce(); waiter.TrySetResult(true); }
Aqui fazemos isso em um loop até conseguirmos retirá-lo. Para não desperdiçar os ciclos do processador novamente, usamos o SpinWait.
Nas primeiras iterações, ele girará em um loop. Se houver muitas iterações, o garçom não aparecerá por um longo período de tempo, então nosso encadeamento irá para Thread.Sleep, para não desperdiçar recursos da CPU novamente.
De fato, o semáforo de ordem LIFO não é apenas nossa ideia.
LowLevelLifoSemaphore
- Síncrona
- No Windows, usa a porta IO Completion como uma pilha do Windows
https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs
Existe esse semáforo no próprio .NET, mas não no CoreCLR, não no CoreFX, mas no CoreRT. Às vezes, é bastante útil espreitar o repositório .NET. Existe um semáforo chamado LowLevelLifoSemaphore. Este semáforo não seria adequado para nós de qualquer maneira: é síncrono.
Notavelmente, no Windows, ele funciona através das portas de conclusão de E / S. Eles têm a propriedade de que os threads podem esperar por eles e serão liberados apenas na ordem LIFO. Esse recurso é usado lá, é realmente de baixo nível.
2.3 Conclusões:
- Não espere que o preenchimento da estrutura sobreviva sob sua carga
- É mais fácil resolver um problema específico do que o caso geral.
- O teste de estresse nem sempre ajuda
- Cuidado com o bloqueio
Quais são as conclusões de toda essa história? Primeiro de tudo, não espere que algumas classes da estrutura que você usa da biblioteca padrão possam lidar com sua carga. Não quero dizer que o SemaphoreSlim é ruim, acabou sendo inadequado especificamente nesse cenário.
Achamos muito mais fácil escrever nosso semáforo para uma tarefa específica. Por exemplo, ele não suporta cancelamento de espera. Esse recurso está disponível no SemaphoreSlim usual, não o temos, mas isso nos permitiu simplificar o código.
O teste de carga, embora ajude, nem sempre pode ajudar.
.NET , — . lock, : « ?» CPU 100%, lock', , , - .NET. .
.
3: (A)sync IO
/, .

lock convoy, stack trace Overlapped PinnableBufferCache. lock. : Overlapped PinnableBufferCache?
OVERLAPPED — Windows, /. , . , . , lock convoy. , lock convoy, , .

, , .NET 4.5.1 4.5.2. .NET 4.5.2, , .NET 4.5.2. .NET 4.5.1 OverlappedDataCache, Overlapped — , , . , lock-free, ConcurrentStack, . .NET 4.5.2 : OverlappedDataCache PinnableBufferCache.
? PinnableBufferCache , Overlapped , , — . , , . PinnableBufferCache . , lock-free, ConcurrentStack. , . , , - lock-free list lock'.
3.1 PinnableBufferCache
LockConvoy:
lock convoy , - . list , lock , , .
PinnableBufferCache , . :
PinnableBufferCache_System.ThreadingOverlappedData_MinCount
, . : « ! - ». -:
Environment.SetEnvironmentVariable( "PinnableBufferCache_System.Threading.OverlappedData_MinCount", "10000"); new Overlapped().GetHashCode(); for (int i = 0; i < 3; i++) GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
? , Overlapped , , . , , , , PinnableBufferCache lock convoy'. , .
.NET Core PinnableBufferCache
, OverlappedData . , , Garbage collector , . .NET Core . .NET Framework, , .
3.2 :
, . , .NET , . , , .NET Core. , , -.
key-value .
4: Concurrent key-value collections
.NET concurrent-. lock-free ConcurrentStack ConcurrentQueu, . ConcurrentDictionary, . lock-free , , . ConcurrentDictionary?
4.1 ConcurrentDictionary
:
Prós:
- (TryAdd/TryUpdate/AddOrUpdate)
- Lock-free
- Lock-free enumeration
, memory-, , . , , .NET Framework. . , , (enumeration) lock-free. , .
, , - .NET. key-value - :

-, bucket'. bucket', . , bucket , .
— , ConcurrentDictionary. ConcurrentDictionary «-» . , , , memory traffic. ConcurrentDictionary, lock'. — .
, Dictionary.

Dictionary , Concurrent, . : buckets, entries. buckets bucket' entries. «-» entries. . «-» int, bucket'.
memory overhead, ConcurrentDictionary Dictionary.

Dictionary. Memory overhea' , . Dictionary overhead - , int'. 8 .
ConcurrentDictionary. ConcurrentDictionary ConcurrentDictionary.Node. , . int hashCode . , table ( 16 ), int hashCode . , 64- 28 overhead'. Dictionary.
memory overhead', ConcurrentDictionary GC , . Benchmark. ConcurrentDictionary , GC.Collect. ?

. ConcurrentDictionary 10 , , , . Dictionary . , , , . .
, ConcurrentDictionary?
4.2
- TTL
- Dictionary+lock
- Sharding
. ConcurrentDictionary. 10 . , . TTL , . Dictionary lock'. , , lock . Dictionary lock' , - , lock. , .
4.3
- in-memory <Guid,Guid>
- >10 6
. — , in-memory Guid' Guid, . . - - , . , 15 . . Semaphore ConcurrentDictionary.

, lock-free , overhead GC. , . , , , . , - , , . , , Large Object Heap. ?
, , Dictionary .

Dictionary bucket', Entry. Entry , , , .

Dictionary , , . , - .
, - ? -, , , , . . Dictionary, , buckets, entries, Interlocked. , .
Dictionary
- ,
- , ?
— Resize buckets entries
— -
— Dictionary.Entry
— -
https://blogs.msdn.microsoft.com/tess/2009/12/21/high-cpu-in-net-app-using-a-static-generic-dictionary/
, Dictionary - bucket'. , . , , . , , .
Entry Dictionary. - - . , .

.NET Framework 1.1. Hashtable, Dictionary, object'. MSDN , . , -. . , Hashtable . , .
4.4 Dictionary.Entry

? Dictionary.Entry , , 8 , , , , . ?
bool writing; int version; this.writing = true; buckets[index] = …; this.version++; this.writing = false;
: ( , ) int-. , . , , , , .
bool writing; int version; while (true) { int version = this.version; bucket = bickets[index]; if (this.writing || version != this.version) continue; break; }
, , . , . , 8 .
4.5 -
, .

Dictionary bucket , .
Dictionary, . : 0 2. bucket, 1 2. ? 0. , , 2. . , 2, , , 1. 1 2 — bucket. , , . 1 — , bucket. Hashtable , bucket' -. —
double hashing .
4.6
. , Buckets, Entries ( Buckets, Entries). - , , , , .
. , .
: , , , , . , , .

, , — .
? , - 2. - Capacity , . — 2. , . 2. ? , , , . - , , 3. , , , , , .
, Hashtable, . , double hashing. , , , .
, , — , . Hashtable. , — — . . , bucket', - , . .
, , lock-free LOH.

lock-free ? MSDN Hashtable , . , , .

, , , bucket'. Dictionary bucket', -, bucket' . - bucket, bucket . , .
, Large Object Heap.

. CustomDictionary CustomDictionarySegment . Dictionary, , . — Dictionary, . , Large Object Heap. , bucket' . , , , bucket, - - .
. ConcurrentDictionary, .NET, , .
4.7
? .NET . . , , . - — - . , , , .
- , , , , . , , , , , . — , , .
— ConcurrentDictionary. , , (
Diafilm ), .
GitHub. — , , LIFO-Semaphore, . , .
6-7 DotNext 2019 Moscow «.NET: » , .NET Framework .NET Core, , .