O desenvolvimento de software pode ser um processo muito complicado. Nós, como desenvolvedores, precisamos levar em consideração muitas variáveis diferentes. Alguns não estão sob nosso controle, alguns são desconhecidos para nós no momento da execução real do código e alguns são controlados diretamente por nós. E Desenvolvedores .NET não são exceção a isso.
Diante dessa realidade, normalmente as coisas saem conforme o planejado quando trabalhamos em ambientes controlados. Um exemplo é nossa máquina de desenvolvimento, ou um ambiente de integração ao qual temos acesso total. Nessas situações, temos à nossa disposição ferramentas para analisar diferentes variáveis que estão afetando nosso código e software. Nesses casos, também não precisamos lidar com cargas pesadas do servidor ou usuários simultâneos tentando fazer a mesma coisa ao mesmo tempo.
Em situações descritas e seguras, nosso código funcionará bem, mas em produção sob carga pesada ou alguns outros fatores externos, podem ocorrer problemas inesperados. O desempenho do software em produção é difícil de analisar. Na maioria das vezes temos que lidar com problemas potenciais em um cenário teórico: sabemos que um problema pode acontecer, mas não podemos testá-lo. É por isso que precisamos basear nosso desenvolvimento nas melhores práticas e documentação para a linguagem que estamos usando, e evitar erros comuns .
Conforme mencionado, quando o software entra no ar, as coisas podem dar errado e o código pode começar a ser executado de uma maneira que não planejamos. Podemos acabar na situação em que temos que lidar com problemas sem a capacidade de depurar ou saber com certeza o que está acontecendo. O que podemos fazer neste caso?
Neste artigo, vamos analisar um cenário de caso real de alto uso da CPU de um Aplicativo da web .NET no servidor baseado em Windows, os processos envolvidos para identificar o problema e, mais importante, por que esse problema aconteceu em primeiro lugar e como o resolvemos.
O uso da CPU e o consumo de memória são tópicos amplamente discutidos. Normalmente é muito difícil saber com certeza qual é a quantidade certa de recursos (CPU, RAM, E / S) que um processo específico deve estar usando e por quanto tempo. Embora uma coisa seja certa - se um processo estiver usando mais de 90% da CPU por um longo período de tempo, estaremos em apuros apenas porque o servidor não será capaz de processar qualquer outra solicitação sob esta circunstância.
Isso significa que há um problema com o próprio processo? Não necessariamente. Pode ser que o processo precise de mais poder de processamento ou esteja lidando com muitos dados. Para começar, a única coisa que podemos fazer é tentar identificar por que isso está acontecendo.
Todos os sistemas operacionais possuem várias ferramentas diferentes para monitorar o que está acontecendo em um servidor. Os servidores Windows possuem especificamente o gerenciador de tarefas, Monitor de Desempenho , ou no nosso caso usamos Servidores New Relic que é uma ótima ferramenta para monitorar servidores.
Depois de implantar nosso aplicativo, durante um lapso de tempo das primeiras duas semanas, começamos a ver que o servidor tinha picos de uso da CPU, o que fez com que o servidor parasse de responder. Tivemos que reiniciá-lo para disponibilizá-lo novamente, e esse evento aconteceu três vezes durante esse período. Como mencionei antes, usamos servidores New Relic como monitor de servidor e isso mostrou que o w3wp.exe
o processo estava usando 94% da CPU no momento em que o servidor travou.
Um processo de trabalho dos Serviços de Informações da Internet (IIS) é um processo do Windows (w3wp.exe
) que executa aplicativos da Web e é responsável por lidar com as solicitações enviadas a um servidor Web para um pool de aplicativos específico. O servidor IIS pode ter vários pools de aplicativos (e vários processos w3wp.exe
diferentes) que podem estar gerando o problema. Com base no usuário que o processo tinha (isso foi mostrado nos relatórios da New Relic), identificamos que o problema era nosso aplicativo legado de formulário da web .NET C #.
O .NET Framework é totalmente integrado às ferramentas de depuração do Windows, então a primeira coisa que tentamos fazer foi olhar o visualizador de eventos e os arquivos de log do aplicativo para encontrar algumas informações úteis sobre o que estava acontecendo. Se tínhamos algumas exceções registradas no visualizador de eventos, eles não forneceram dados suficientes para análise. É por isso que decidimos dar um passo adiante e coletar mais dados, para que quando o evento surgisse novamente estaríamos preparados.
A maneira mais fácil de coletar despejos de processo no modo de usuário é com Ferramentas de diagnóstico de depuração v2.0 ou simplesmente DebugDiag. DebugDiag possui um conjunto de ferramentas para coleta de dados (DebugDiag Collection) e análise de dados (DebugDiag Analysis).
Então, vamos começar a definir regras para coletar dados com ferramentas de diagnóstico de depuração:
Abra a coleção DebugDiag e selecione Performance
.
Performance Counters
e clique em Next
.Add Perf Triggers
.Processor
(não o objeto Process
) e selecione % Processor Time
. Observe que se você estiver no Windows Server 2008 R2 e tiver mais de 64 processadores, escolha o Processor Information
objeto em vez do Processor
objeto._Total
.Add
e clique em OK
.Selecione o gatilho recém-adicionado e clique em Edit Thresholds
.
Above
na lista suspensa.80
.Digite 20
para o número de segundos. Você pode ajustar esse valor se necessário, mas tome cuidado para não especificar um pequeno número de segundos para evitar disparos falsos.
OK
.Next
.Add Dump Target
.Web Application Pool
na lista suspensa.OK
.Next
.Next
novamente.Next
.Activate the Rule Now
e clique em Finish
.A regra descrita criará um conjunto de arquivos minidespejo de tamanho bastante pequeno. O dump final será um dump com memória cheia e esses dumps serão muito maiores. Agora, só precisamos esperar que o evento de alta CPU aconteça novamente.
Assim que tivermos os arquivos de despejo na pasta selecionada, usaremos a ferramenta Análise DebugDiag para analisar os dados coletados:
Selecione Analisadores de desempenho.
Adicione os arquivos de despejo.
Inicie a análise.
DebugDiag levará alguns (ou vários) minutos para analisar os dumps e fornecer uma análise. Quando a análise for concluída, você verá uma página da web com um resumo e muitas informações sobre os threads, semelhante a esta:
Como você pode ver no resumo, há um aviso que diz “Foi detectado alto uso da CPU entre os arquivos de despejo em um ou mais threads”. Se clicarmos na recomendação, começaremos a entender onde está o problema com nosso aplicativo. Nosso relatório de exemplo é semelhante a este:
Como podemos ver no relatório, existe um padrão em relação ao uso da CPU. Todos os threads que têm alto uso de CPU estão relacionados à mesma classe. Antes de pular para o código, vamos dar uma olhada no primeiro.
Este é o detalhe do primeiro thread com nosso problema. A parte que nos interessa é a seguinte:
Aqui temos uma chamada para o nosso código GameHub.OnDisconnected()
que acionou a operação problemática, mas antes dessa chamada temos duas chamadas de Dicionário, que podem dar uma ideia do que está acontecendo. Vamos dar uma olhada no código .NET para ver o que esse método está fazendo:
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
Obviamente temos um problema aqui. A pilha de chamadas dos relatórios dizia que o problema era com um Dicionário, e neste código estamos acessando um dicionário, e especificamente a linha que está causando o problema é esta:
if (onlineSessions.TryGetValue(userId, out connId))
Esta é a declaração do dicionário:
static Dictionary onlineSessions = new Dictionary();
Todos que têm experiência em programação orientada a objetos sabem que as variáveis estáticas serão compartilhadas por todas as instâncias desta classe. Vamos dar uma olhada em mais detalhes sobre o que estático significa no mundo .NET.
De acordo com a especificação .NET C #:
Use o estático modificador para declarar um membro estático, que pertence ao próprio tipo em vez de a um objeto específico.
Isso é o que as especificações do .NET C # langunge dizem sobre classes e membros estáticos :
Como acontece com todos os tipos de classe, as informações de tipo para uma classe estática são carregadas pelo CLR (common language runtime) do .NET Framework quando o programa que faz referência à classe é carregado. O programa não pode especificar exatamente quando a classe é carregada. No entanto, é garantido que ele seja carregado e tenha seus campos inicializados e seu construtor estático chamado antes que a classe seja referenciada pela primeira vez em seu programa. Um construtor estático é chamado apenas uma vez, e uma classe estática permanece na memória pelo tempo de vida do domínio do aplicativo no qual o programa reside.
Uma classe não estática pode conter métodos, campos, propriedades ou eventos estáticos. O membro estático pode ser chamado em uma classe, mesmo quando nenhuma instância da classe foi criada. O membro estático sempre é acessado pelo nome da classe, não pelo nome da instância. Existe apenas uma cópia de um membro estático, independentemente de quantas instâncias da classe são criadas. Métodos e propriedades estáticos não podem acessar campos e eventos não estáticos em seu tipo de conteúdo e não podem acessar uma variável de instância de qualquer objeto, a menos que seja explicitamente passado em um parâmetro de método.
Isso significa que os membros estáticos pertencem ao próprio tipo, não ao objeto. Eles também são carregados no domínio do aplicativo pelo CLR, portanto, os membros estáticos pertencem ao processo que está hospedando o aplicativo e não a threads específicos.
Dado o fato de que um ambiente da web é um ambiente multithread, porque cada solicitação é um novo thread que é gerado pelo w3wp.exe
processo; e dado que os membros estáticos são parte do processo, podemos ter um cenário no qual vários threads diferentes tentam acessar os dados de variáveis estáticas (compartilhadas por vários threads), o que pode eventualmente levar a problemas de multithreading.
O dicionário documentação em thread de segurança afirma o seguinte:
A
Dictionary
pode suportar vários leitores ao mesmo tempo, desde que a coleção não seja modificada. Mesmo assim, enumerar por meio de uma coleção não é intrinsecamente um procedimento thread-safe. No caso raro em que uma enumeração contende com acessos de gravação, a coleção deve ser bloqueada durante toda a enumeração. Para permitir que a coleção seja acessada por vários threads para leitura e gravação, você deve implementar sua própria sincronização.
Esta declaração explica porque podemos ter esse problema. Com base nas informações de despejo, o problema era com o método FindEntry do dicionário:
Se olharmos para o dicionário FindEntry implementação podemos ver que o método itera através da estrutura interna (buckets) para encontrar o valor.
Portanto, o código .NET a seguir está enumerando a coleção, o que não é uma operação thread-safe.
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
Como vimos nos dumps, existem vários encadeamentos tentando iterar e modificar um recurso compartilhado (dicionário estático) ao mesmo tempo, o que eventualmente fez com que a iteração entrasse em um loop infinito, fazendo com que o encadeamento consumisse mais de 90% da CPU .
Existem várias soluções possíveis para este problema. O que implementamos primeiro foi bloquear e sincronizar o acesso ao dicionário ao custo de perder desempenho. O servidor estava travando todos os dias naquele horário, então precisávamos consertar isso o mais rápido possível. Mesmo que essa não fosse a solução ideal, ela resolveu o problema.
O próximo passo para resolver esse problema seria analisar o código e encontrar a solução ideal para isso. Refatorar o código é uma opção: novo ConcurrentDictionary classe pode resolver esse problema porque ela bloqueia apenas em um nível de bucket que melhorará o desempenho geral. Embora este seja um grande passo, uma análise mais aprofundada seria necessária.