Escrevendo High-Performance aplicativos gerenciados: um Primer

 

Gregor Noriskin
Equipe de desempenho do Microsoft CLR

Junho de 2003

Aplica-se a:
   Microsoft® .NET Framework

Resumo: Saiba mais sobre o Common Language Runtime do .NET Framework de uma perspectiva de desempenho. Saiba como identificar as práticas recomendadas de desempenho de código gerenciado e como medir o desempenho do aplicativo gerenciado. (19 páginas impressas)

Baixe o CLR Profiler. (330 KB)

Sumário

Malabarismo como metáfora para o desenvolvimento de software
O Common Language Runtime do .NET
Dados Gerenciados e o Coletor de Lixo
Perfis de Alocação
A API de Criação de Perfil e o Criador de Perfil CLR
Hospedando o GC do Servidor
Finalização
O Padrão de Descarte
Uma observação sobre referências fracas
Código gerenciado e o JIT do CLR
Tipos de valor
Tratamento de exceção
Threading e sincronização
Reflexão
Associação tardia
Segurança
Interoperabilidade COM e invocação de plataforma
Contadores de desempenho
Outras Ferramentas
Conclusão
Recursos

Malabarismo como metáfora para o desenvolvimento de software

O malabarismo é uma ótima metáfora para descrever o processo de desenvolvimento de software. O malabarismo normalmente requer pelo menos três itens, embora não haja limite superior para o número de itens que você pode tentar fazer malabarismo. Quando você começa a aprender a fazer malabarismo, você descobre que watch cada bola individualmente à medida que você as pega e joga. À medida que avança, você começa a se concentrar no fluxo das bolas, em oposição a cada bola individual. Quando você domina o malabarismo, você pode mais uma vez se concentrar em uma única bola, equilibrando essa bola em seu nariz, enquanto continua a fazer malabarismo com os outros. Você sabe intuitivamente onde as bolas vão estar e pode colocar sua mão no lugar certo para pegá-las e jogá-las. Então, como isso é como o desenvolvimento de software?

Diferentes funções no processo de desenvolvimento de software fazem malabarismo com diferentes "trinities"; Os Gerentes de Projetos e Programas fazem malabarismos com recursos, recursos e tempo e os desenvolvedores de software fazem malabarismos de correção, desempenho e segurança. Pode-se sempre tentar fazer malabarismos com mais itens, mas como qualquer aluno de malabarismo pode atestar, adicionar uma única bola torna exponencialmente mais difícil manter as bolas no ar. Tecnicamente, se você está fazendo malabarismo com menos de três bolas você não está fazendo malabarismo. Se, como desenvolvedor de software, você não estiver considerando a exatidão, o desempenho e a segurança do código que está escrevendo, o caso poderá ser feito de que você não está fazendo seu trabalho. Ao começar a considerar a correção, o desempenho e a segurança inicialmente, você terá que se concentrar em um aspecto de cada vez. À medida que eles se tornam parte da sua prática diária, você descobrirá que não precisa se concentrar em um aspecto específico, eles simplesmente farão parte da maneira como você trabalha. Quando você os dominar, poderá fazer compensações intuitivamente e concentrar seus esforços adequadamente. E como no malabarismo, a prática é a chave.

Escrever código de alto desempenho tem uma trindade própria; Definindo metas, medição e entendendo a plataforma de destino. Se você não souber a rapidez com que seu código precisa ser, como saberá quando terminar? Se você não medir e criar o perfil do código, como saberá quando atingiu suas metas ou por que não está cumprindo suas metas? Se você não entender a plataforma que está direcionando, como saberá o que otimizar caso não esteja cumprindo suas metas. Esses princípios se aplicam ao desenvolvimento de código de alto desempenho em geral, seja qual for a plataforma que você estiver direcionando. Nenhum artigo sobre como escrever código de alto desempenho estaria completo sem mencionar essa trindade. Embora os três sejam igualmente significativos, este artigo se concentrará nos dois últimos aspectos à medida que se aplicam à escrita de aplicativos de alto desempenho direcionados ao microsoft® .NET Framework.

Os princípios fundamentais da escrita de código de alto desempenho em qualquer plataforma são:

  1. Definir metas de desempenho
  2. Medir, medir e, em seguida, medir um pouco mais
  3. Entender as plataformas de hardware e software que seu aplicativo está direcionando

O Common Language Runtime do .NET

O núcleo do .NET Framework é o CLR (Common Language Runtime). O CLR fornece todos os serviços de runtime para seu código; Compilação Just-In-Time, Gerenciamento de Memória, Segurança e vários outros serviços. O CLR foi projetado para ser de alto desempenho. Dito isso, há maneiras de aproveitar esse desempenho e maneiras de impedi-lo.

O objetivo deste artigo é fornecer uma visão geral do Common Language Runtime de uma perspectiva de desempenho, identificar as melhores práticas de desempenho de código gerenciado e mostrar como você pode medir o desempenho do seu aplicativo gerenciado. Este artigo não é uma discussão exaustiva sobre as características de desempenho do .NET Framework. Para os fins deste artigo, definirei o desempenho para incluir taxa de transferência, escalabilidade, tempo de inicialização e uso de memória.

Dados Gerenciados e o Coletor de Lixo

Uma das principais preocupações dos desenvolvedores em usar código gerenciado em aplicativos críticos ao desempenho é o custo do gerenciamento de memória do CLR, que é realizado pelo Coletor de Lixo (GC). O custo do gerenciamento de memória é uma função do custo de alocação de memória associado a uma instância de um tipo, o custo de gerenciar essa memória durante o tempo de vida da instância e o custo de liberar essa memória quando ela não for mais necessária.

Uma alocação gerenciada geralmente é muito barata; na maioria dos casos, leva menos tempo do que um C/C++ malloc ou new. Isso ocorre porque o CLR não precisa examinar uma lista livre para localizar o próximo bloco contíguo de memória disponível grande o suficiente para manter o novo objeto; mantém um ponteiro para a próxima posição livre na memória. Pode-se pensar em alocações de heap gerenciadas como sendo "como pilha". Uma alocação poderá causar uma coleção se o GC precisar liberar memória para alocar o novo objeto; nesse caso, a alocação é mais cara que um malloc ou new. Objetos fixados também podem afetar o custo de alocação. Objetos fixados são objetos que o GC foi instruído a não mover durante uma coleção, normalmente porque o endereço do objeto foi passado para uma API nativa.

Ao contrário de um malloc ou new, há um custo associado ao gerenciamento de memória durante o tempo de vida de um objeto. O CLR GC é geracional, o que significa que o heap inteiro nem sempre é coletado. No entanto, o GC ainda precisa saber se algum objeto ativo no restante dos objetos raiz de heap na parte do heap que está sendo coletado. A memória que contém objetos que contêm referências a objetos em gerações mais jovens é cara para gerenciar ao longo do tempo de vida dos objetos.

O GC é um coletor de lixo de marca geração e varredura. O heap gerenciado contém três gerações; A geração 0 contém todos os novos objetos, a Geração 1 contém objetos um pouco mais longos e a Geração 2 contém objetos de longa duração. O GC coletará a menor seção do heap possível para liberar memória suficiente para o aplicativo continuar. A coleção de uma Geração inclui a coleção de todas as gerações mais jovens, nesse caso, uma coleção de Geração 1 também coleta a Geração 0. A geração 0 é dimensionada dinamicamente de acordo com o tamanho do cache do processador e a taxa de alocação do aplicativo e normalmente leva menos de 10 milissegundos para coletar. A geração 1 é dimensionada dinamicamente de acordo com a taxa de alocação do aplicativo e normalmente leva entre 10 e 30 milissegundos para coletar. O tamanho da geração 2 dependerá do perfil de alocação do aplicativo, assim como o tempo necessário para coletar. São essas coleções de Geração 2 que afetarão mais significativamente o custo de desempenho do gerenciamento da memória de seus aplicativos.

DICA O GC é autoajuste e se ajustará de acordo com os requisitos de memória dos aplicativos. Na maioria dos casos, invocar programaticamente um GC dificultará esse ajuste. "Ajudando" o GC chamando GC. Coletar provavelmente não melhorará o desempenho dos aplicativos.

O GC pode realocar objetos ativos durante uma coleção. Se esses objetos forem grandes, o custo de realocação será alto para que esses objetos sejam alocados em uma área especial do heap chamada Heap de Objetos Grandes. O Heap de Objeto Grande é coletado, mas não é compactado, por exemplo, objetos grandes não são realocados. Objetos grandes são aqueles que têm mais de 80 kb. Observe que isso pode mudar em versões futuras do CLR. Quando o Heap de Objeto Grande precisa ser coletado, ele força uma coleção completa e o Heap de Objetos Grandes é coletado durante as coleções gen 2. A alocação e a taxa de mortalidade de objetos no Heap de Objetos Grandes podem ter um efeito significativo no custo de desempenho do gerenciamento da memória dos aplicativos.

Perfis de Alocação

O perfil de alocação geral de um aplicativo gerenciado definirá o quanto o Coletor de Lixo precisa trabalhar para gerenciar a memória associada ao aplicativo. Quanto mais difícil o GC tiver de trabalhar no gerenciamento da memória, maior será o número de ciclos de CPU que o GC levará e menos tempo a CPU passará executando o código do aplicativo. O perfil de alocação é uma função do número de objetos alocados, do tamanho desses objetos e de seus tempos de vida. A maneira mais óbvia de aliviar a pressão do GC é simplesmente alocar menos objetos. Aplicativos projetados para extensibilidade, modularidade e reutilização usando técnicas de design orientadas a objeto quase sempre resultarão em um número maior de alocações. Há uma penalidade de desempenho para abstração e "elegância".

Um perfil de alocação amigável do GC terá alguns objetos alocados no início do aplicativo e, em seguida, sobreviverá durante o tempo de vida do aplicativo e, em seguida, todos os outros objetos de curta duração. Objetos de longa duração conterão poucas ou nenhuma referência a objetos de curta duração. À medida que o perfil de alocação se desvia disso, o GC terá que trabalhar mais para gerenciar a memória dos aplicativos.

Um perfil de alocação GC-unfriendly terá muitos objetos sobrevivendo à Geração 2 e, em seguida, morrendo, ou terá muitos objetos de curta duração sendo alocados no Heap de Objetos Grandes. Objetos que sobrevivem tempo suficiente para entrar na Geração 2 e depois morrer são os mais caros para gerenciar. Como mencionei antes de objetos em gerações mais antigas que contêm referências a objetos em gerações mais jovens durante um GC, também aumentam o custo da coleção.

Um perfil de alocação típico do mundo real estará em algum lugar entre os dois perfis de alocação mencionados acima. Uma métrica importante do seu perfil de alocação é a porcentagem do tempo total de CPU que está sendo gasto no GC. Você pode obter esse número na Memória CLR do .NET: % de tempo no contador de desempenho do GC. Se o valor médio desse contador estiver acima de 30%, você provavelmente deve considerar dar uma olhada mais de perto em seu perfil de alocação. Isso não significa necessariamente que seu perfil de alocação seja "ruim"; há alguns aplicativos com uso intensivo de memória em que esse nível de GC é necessário e apropriado. Esse contador deve ser a primeira coisa que você examinará se tiver problemas de desempenho; ele deverá mostrar imediatamente se o perfil de alocação faz parte do problema.

DICA Se o contador de desempenho Memória CLR do .NET: % de tempo no GC indicar que seu aplicativo está gastando uma média de mais de 30% de seu tempo no GC, você deverá examinar mais detalhadamente seu perfil de alocação.

DICA Um aplicativo amigável ao GC terá significativamente mais coleções de Geração 0 do que a Geração 2. Essa proporção pode ser estabelecida comparando os contadores de desempenho Memória DO NET CLR: # Gen 0 Coleções e MEMÓRIA CLR NET: # Gen 2 Coleções.

A API de Criação de Perfil e o Criador de Perfil CLR

O CLR inclui uma poderosa API de criação de perfil que permite que terceiros escrevam profilers personalizados para aplicativos gerenciados. O CLR Profiler é uma ferramenta de exemplo de criação de perfil de alocação sem suporte, escrita pela Equipe de Produto clr, que usa essa API de criação de perfil. O CLR Profiler permite que os desenvolvedores vejam o perfil de alocação de seus aplicativos de gerenciamento.

Figura 1 Janela Principal do Criador de Perfil CLR

O CLR Profiler inclui várias exibições muito úteis do perfil de alocação, incluindo um histograma de tipos alocados, grafos de alocação e chamada, uma linha de tempo mostrando GCs de várias gerações e o estado resultante do heap gerenciado após essas coleções e uma árvore de chamadas mostrando alocações por método e cargas de assembly.

Figura 2 Grafo de Alocação do Criador de Perfil CLR

DICA Para obter detalhes sobre como usar o CLR Profiler, consulte o arquivo leiame incluído no zip.

Observe que o CLR Profiler tem uma sobrecarga de alto desempenho e altera significativamente as características de desempenho do seu aplicativo. Bugs de estresse emergentes provavelmente desaparecerão quando você executar seu aplicativo com o CLR Profiler.

Hospedando o GC do Servidor

Dois coletores de lixo diferentes estão disponíveis para o CLR: um GC de estação de trabalho e um GC de servidor. Aplicativos de console e Windows Forms hospedam o GC da Estação de Trabalho e ASP.NET hospeda o GC do Servidor. O GC do Servidor é otimizado para taxa de transferência e escalabilidade de vários processadores. O GC do servidor pausa todos os threads que executam o código gerenciado durante toda a duração de uma coleção, incluindo as Fases de Marca e Varredura, e o GC ocorre em paralelo em todas as CPUs disponíveis para o processo em threads dedicados com afinidade de CPU de alta prioridade. Se os threads estiverem executando código nativo durante um GC, esses threads serão pausados somente quando a chamada nativa retornar. Se você estiver criando um aplicativo de servidor que será executado em computadores com vários processadores, é altamente recomendável que você use o GC do Servidor. Se o aplicativo não estiver hospedado por ASP.NET, você precisará escrever um aplicativo nativo que hospede explicitamente o CLR.

DICA Se você estiver criando aplicativos de servidor escalonáveis, hospede o GC do Servidor. Consulte Implementar um host personalizado do Common Language Runtime para seu aplicativo gerenciado.

O GC da Estação de Trabalho é otimizado para baixa latência, o que normalmente é necessário para aplicativos cliente. Não se deseja uma pausa perceptível em um aplicativo cliente durante um GC, pois normalmente o desempenho do cliente não é medido pela taxa de transferência bruta, mas sim pelo desempenho percebido. O GC da Estação de Trabalho faz GC simultâneo, o que significa que ele faz a Fase de Marca enquanto o código gerenciado ainda está em execução. O GC pausará apenas os threads que estão executando o código gerenciado quando precisar realizar a Fase de Varredura. No GC da Estação de Trabalho, o GC é feito apenas em um thread e, portanto, em apenas uma CPU.

Finalização

O CLR fornece um mecanismo pelo qual limpo-up é feito automaticamente antes que a memória associada a uma instância de um tipo seja liberada. Esse mecanismo é chamado de Finalização. Normalmente, a Finalização é usada para liberar recursos nativos, nesse caso, conexões de banco de dados ou identificadores do sistema operacional que estão sendo usados por um objeto .

A finalização é um recurso caro e aumenta a pressão que é colocada no GC. O GC rastreia os objetos que exigem finalização em uma fila finalizável. Se durante uma coleção o GC encontrar um objeto que não está mais ativo, mas requer finalização, a entrada desse objeto na Fila Finalizável será movida para a Fila FReachable. A finalização ocorre em um thread separado chamado Finalizer Thread. Como todo o estado do objeto pode ser necessário durante a execução do Finalizador, o objeto e todos os objetos para os quais ele aponta são promovidos para a próxima geração. A memória associada ao objeto ou grafo de objetos só é liberada durante o GC a seguir.

Os recursos que precisam ser liberados devem ser encapsulados em um objeto Finalizable o menor possível; por exemplo, se sua classe exigir referências a recursos gerenciados e não gerenciados, você deverá encapsular os recursos não gerenciados em uma nova classe Finalizable e tornar essa classe um membro da sua classe. A classe pai não deve ser Finalizável. Isso significa que somente a classe que contém os recursos não gerenciados será promovida (supondo que você não tenha uma referência à classe pai na classe que contém os recursos não gerenciados). Outra coisa a ter em mente é que há apenas um Thread de Finalização. Se um Finalizador fizer esse thread ser bloqueado, os finalizadores subsequentes não serão chamados, os recursos não serão liberados e seu aplicativo vazará.

DICA Os finalizadores devem ser mantidos o mais simples possível e nunca devem ser bloqueados.

DICA Torne finalizável apenas a classe wrapper em torno de objetos não gerenciados que precisam de limpeza.

A finalização pode ser considerada como uma alternativa à contagem de referências. Um objeto que implementa a contagem de referência mantém o controle de quantos outros objetos têm referências a ele (o que pode levar a alguns problemas muito conhecidos), para que ele possa liberar seus recursos quando sua contagem de referência for zero. O CLR não implementa a contagem de referências, portanto, ele precisa fornecer um mecanismo para liberar recursos automaticamente quando não houver mais referências ao objeto sendo mantidas. A finalização é esse mecanismo. A finalização normalmente só é necessária no caso em que o tempo de vida de um objeto que requer limpo-up não é explicitamente conhecido.

O Padrão de Descarte

Caso o tempo de vida do objeto seja explicitamente conhecido, os recursos não gerenciados associados a um objeto devem ser liberados ansiosamente. Isso é chamado de "Descartando" o objeto . O Padrão de Descarte é implementado por meio da interface IDisposable (embora implementá-lo por conta própria seria trivial). Se você quiser disponibilizar a finalização adiantada para sua classe, por exemplo, tornar as instâncias da classe descartáveis, precisará fazer com que o objeto implemente a interface IDisposable e forneça uma implementação para o método Dispose . No método Dispose , você chamará o mesmo código de limpeza que está no Finalizador e informará ao GC que ele não precisa mais finalizar o objeto chamando o GC. Método SuppressFinalization . É uma boa prática fazer com que o método Dispose e o Finalizador chamem uma função de finalização comum para que apenas uma versão do código limpo-up precise ser mantida. Além disso, se a semântica do objeto for tal que um método Close será mais lógico do que um método Dispose , um Close também deverá ser implementado; nesse caso, uma conexão de banco de dados ou um soquete são logicamente "fechados". O Close pode simplesmente chamar o método Dispose .

É sempre uma boa prática fornecer um método Dispose para classes com um Finalizador; nunca se pode ter certeza de como essa classe será usada, por exemplo, se seu tempo de vida será explicitamente conhecido ou não. Se uma classe que você está usando implementar o padrão Dispose e você souber explicitamente quando terminar de usar o objeto, definitivamente chame Dispose.

DICA Forneça um método Dispose para todas as classes que são finalizáveis.

DICA Suprima a Finalização em seu método Dispose .

DICA Chame uma função de limpeza comum.

DICA Se um objeto que você está usando implementa IDisposable e você sabe que o objeto não é mais necessário, chame Dispose.

O C# fornece uma maneira muito conveniente de descartar objetos automaticamente. O using palavra-chave permite identificar um bloco de código após o qual Dispose será chamado em vários objetos descartáveis.

C#s usando palavra-chave

using(DisposableType T)
{
   //Do some work with T
}
//T.Dispose() is called automatically

Uma observação sobre referências fracas

Qualquer referência a um objeto que esteja na pilha, em um registro, em outro objeto ou em uma das outras Raízes do GC manterá um objeto ativo durante um GC. Normalmente, isso é uma coisa muito boa, considerando que isso geralmente significa que seu aplicativo não é feito com esse objeto. No entanto, há casos em que você deseja ter uma referência a um objeto , mas não deseja afetar seu tempo de vida. Nesses casos, o CLR fornece um mecanismo chamado Referências Fracas para fazer exatamente isso. Qualquer referência forte, por exemplo, uma referência que crie raízes em um objeto, pode ser transformada em uma referência fraca. Um exemplo de quando você pode querer usar referências fracas é quando você deseja criar um objeto de cursor externo que possa percorrer uma estrutura de dados, mas não deve afetar o tempo de vida do objeto. Outro exemplo é se você deseja criar um cache liberado quando há pressão de memória; por exemplo, quando um GC acontece.

Criando uma referência fraca em C#

MyRefType mrt = new MyRefType();
//...

//Create weak reference
WeakReference wr = new WeakReference(mrt); 
mrt = null; //object is no longer rooted
//...

//Has object been collected?
if(wr.IsAlive)
{
   //Get a strong reference to the object
   mrt = wr.Target;
   //object is rooted and can be used again
}
else
{
   //recreate the object
   mrt = new MyRefType();
}

Código gerenciado e o JIT do CLR

Os Assemblies Gerenciados, que são a unidade de distribuição para código gerenciado, contêm uma linguagem independente do processador chamada MSIL (Microsoft Intermediate Language ou IL). O JIT (Just-In-Time) do CLR compila a IL em instruções X86 nativas otimizadas. O JIT é um compilador de otimização, mas como a compilação ocorre em runtime e apenas na primeira vez que um método é chamado, o número de otimizações que ele faz precisa ser equilibrado em relação ao tempo necessário para fazer a compilação. Normalmente, isso não é crítico para aplicativos de servidor, pois o tempo de inicialização e a capacidade de resposta geralmente não são um problema, mas são essenciais para aplicativos cliente. Observe que o tempo de inicialização pode ser melhorado fazendo a compilação no momento da instalação usando NGEN.exe.

Muitas das otimizações feitas pelo JIT não têm padrões programáticos associados a elas, por exemplo, você não pode codificar explicitamente para elas, mas há um número que o faz. A próxima seção discute algumas dessas otimizações.

DICA Melhore o tempo de inicialização dos aplicativos cliente compilando seu aplicativo no momento da instalação, usando o utilitário NGEN.exe.

Inlining do método

Há um custo associado a chamadas de método; os argumentos precisam ser enviados por push na pilha ou armazenados em registros, o prólogo e o epílogo do método precisam ser executados e assim por diante. O custo dessas chamadas pode ser evitado para determinados métodos simplesmente movendo o corpo do método do método que está sendo chamado para o corpo do chamador. Isso é chamado de Método In-lining. O JIT usa várias heurísticas para decidir se um método deve ser alinhado. Veja a seguir uma lista dos mais significativos (observe que isso não é exaustivo):

  • Métodos maiores que 32 bytes de IL não serão embutidos.
  • As funções virtuais não estão embutidas.
  • Os métodos que têm controle de fluxo complexo não serão alinhados. O controle de fluxo complexo é qualquer controle de fluxo diferente if/then/else; desse caso, switch ou while.
  • Os métodos que contêm blocos de tratamento de exceções não são embutidos, embora os métodos que geram exceções ainda sejam candidatos a inlining.
  • Se qualquer um dos argumentos formais do método for structs, o método não será embutido.

Eu consideraria cuidadosamente codificar explicitamente para essas heurísticas porque elas podem mudar em versões futuras do JIT. Não comprometa a exatidão do método para tentar garantir que ele será embutido. É interessante observar que as inline palavras-chave e __inline no C++ não garantem que o compilador embutido em um método (embora __forceinline o faça).

Métodos de obtenção e definição de propriedade geralmente são bons candidatos para inlining, pois tudo o que eles fazem normalmente é inicializar membros de dados privados.

**HINT **Não comprometa a exatidão de um método na tentativa de garantir o embutimento.

Eliminação de verificação de intervalo

Um dos muitos benefícios do código gerenciado é a verificação automática de intervalo; sempre que você acessa uma matriz usando a semântica array[index], o JIT emite um marcar para garantir que o índice esteja nos limites da matriz. No contexto de loops com um grande número de iterações e um pequeno número de instruções executadas por iteração, essas verificações de intervalo podem ser caras. Há casos em que o JIT detectará que essas verificações de intervalo são desnecessárias e eliminarão o marcar do corpo do loop, verificando-o apenas uma vez antes do início da execução do loop. No C#, há um padrão programático para garantir que essas verificações de intervalo sejam eliminadas: teste explicitamente o comprimento da matriz na instrução "for". Observe que desvios sutis desse padrão resultarão na eliminação do marcar e, nesse caso, na adição de um valor ao índice.

Eliminação de verificação de intervalo em C#

//Range check will be eliminated
for(int i = 0; i < myArray.Length; i++) 
{
   Console.WriteLine(myArray[i].ToString());
}

//Range check will NOT be eliminated
for(int i = 0; i < myArray.Length + y; i++) 
{ 
   Console.WriteLine(myArray[i+x].ToString());
}

A otimização é particularmente perceptível ao pesquisar grandes matrizes irregulares, por exemplo, pois o intervalo do loop interno e externo marcar são eliminados.

Otimizações que exigem o acompanhamento de uso variável

Um número de otimizações do compilador JIT exige que o JIT acompanhe o uso de argumentos formais e variáveis locais; por exemplo, quando eles são usados pela primeira vez e a última vez que são usados no corpo do método. Nas versões 1.0 e 1.1 do CLR, há uma limitação de 64 no número total de variáveis para as quais o JIT acompanhará o uso. Um exemplo de uma otimização que requer o acompanhamento de uso é Registro. O registro é quando as variáveis são armazenadas em registros de processador em vez de no registro de pilha, por exemplo, na RAM. O acesso às variáveis Enregistered é significativamente mais rápido do que se elas estiverem no quadro de pilha, mesmo que a variável no quadro esteja no cache do processador. Somente 64 variáveis serão consideradas para Registro; todas as outras variáveis serão enviadas por push na pilha. Há outras otimizações diferentes de Registro que dependem do acompanhamento de uso. O número de argumentos formais e locais para um método deve ser mantido abaixo de 64 para garantir o número máximo de otimizações JIT. Tenha em mente que esse número pode mudar para versões futuras do CLR.

DICA Mantenha os métodos curtos. Há vários motivos para isso, incluindo inlining de método, Registro e duração JIT.

Outras otimizações JIT

O compilador JIT faz várias outras otimizações: propagação de constante e cópia, içamento invariável de loop e várias outras. Não há padrões de programação explícitos que você precisa usar para obter essas otimizações; eles são livres.

Por que não vejo essas otimizações no Visual Studio?

Quando você usa Iniciar no menu Depurar ou pressiona F5 para iniciar um aplicativo no Visual Studio, independentemente de ter criado uma versão de Versão ou Depuração, todas as otimizações JIT serão desabilitadas. Quando um aplicativo gerenciado é iniciado por um depurador, mesmo que não seja um build de depuração do aplicativo, o JIT emitirá instruções x86 não otimizadas. Se você quiser que o código JIT emita o código otimizado, inicie o aplicativo no Windows Explorer ou use CTRL+F5 de dentro do Visual Studio. Se você quiser exibir a desmontagem otimizada e contrastá-la com o código não otimizado, poderá usar cordbg.exe.

DICA Use cordbg.exe para ver a desmontagem de código otimizado e não otimizado emitido pelo JIT. Depois de iniciar o aplicativo com cordbg.exe, você pode definir o modo JIT digitando o seguinte:

(cordbg) mode JitOptimizations 1
JIT's will produce optimized code

(cordbg) mode JitOptimizations 0

O JIT produzirá código depurável (não otimizado).

Tipos de valor

O CLR expõe dois conjuntos diferentes de tipos, tipos de referência e tipos de valor. Os tipos de referência são sempre alocados no heap gerenciado e são passados por referência (como o nome indica). Os tipos de valor são alocados na pilha ou embutidos como parte de um objeto no heap e são passados por valor por padrão, embora você também possa passá-los por referência. Os tipos de valor são muito baratos de alocar e, supondo que sejam mantidos pequenos e simples, são baratos de passar como argumentos. Um bom exemplo de um uso apropriado de tipos de valor seria um tipo de valor Point que contém uma coordenada x e y .

Tipo de valor de ponto

struct Point
{
   public int x;
   public int y;
   
   //
}

Os tipos de valor também podem ser tratados como objetos; Por exemplo, os métodos de objeto podem ser chamados neles, eles podem ser convertidos em objeto ou passados onde um objeto é esperado. Quando isso acontece, no entanto, o tipo de valor é convertido em um tipo de referência, por meio de um processo chamado Boxing. Quando um tipo de valor é Boxed, um novo objeto é alocado no heap gerenciado e o valor é copiado para o novo objeto. Essa é uma operação dispendiosa e pode reduzir ou negar totalmente o desempenho obtido usando tipos de valor. Quando o tipo Boxed é convertido implicitamente ou explicitamente para um tipo de valor, ele é Unboxed.

Tipo de valor Box/Unbox

C#:

int BoxUnboxValueType()
{
   int i = 10;
   object o = (object)i; //i is Boxed
   return (int)o + 3; //i is Unboxed
}

MSIL:

.method private hidebysig instance int32
        BoxUnboxValueType() cil managed
{
  // Code size       20 (0x14)
  .maxstack  2
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  ldc.i4.s   10
  IL_0002:  stloc.0
  IL_0003:  ldloc.0
  IL_0004:  box        [mscorlib]System.Int32
  IL_0009:  stloc.1
  IL_000a:  ldloc.1
  IL_000b:  unbox      [mscorlib]System.Int32
  IL_0010:  ldind.i4
  IL_0011:  ldc.i4.3
  IL_0012:  add
  IL_0013:  ret
} // end of method Class1::BoxUnboxValueType

Se você implementar tipos de valor personalizados (struct em C#), considere substituir o método ToString . Se você não substituir esse método, as chamadas para ToString no tipo de valor farão com que o tipo seja Boxed. Isso também é verdadeiro para os outros métodos herdados de System.Object, nesse caso, Equals, embora ToString seja provavelmente o método mais frequentemente chamado. Se você quiser saber se e quando seu tipo de valor está sendo boxed, você pode procurar a box instrução no MSIL usando o utilitário ildasm.exe (como no snippet acima).

Substituindo o método ToString() em C# para evitar conversão boxing

struct Point
{
   public int x;
   public int y;

   //This will prevent type being boxed when ToString is called
   public override string ToString()
   {
      return x.ToString() + "," + y.ToString();
   }
}

Lembre-se de que, ao criar Coleções, por exemplo, uma ArrayList de float, cada item será boxed quando adicionado à coleção. Você deve considerar o uso de uma matriz ou a criação de uma classe de coleção personalizada para seu tipo de valor.

Conversão boxing implícita ao usar classes de coleção em C#

ArrayList al = new ArrayList();
al.Add(42.0F); //Implicitly Boxed becuase Add() takes object
float f = (float)al[0]; //Unboxed

Tratamento de exceção

É uma prática comum usar condições de erro como controle de fluxo normal. Nesse caso, ao tentar adicionar programaticamente um usuário a uma instância do Active Directory, você pode simplesmente tentar adicionar o usuário e, se um E_ADS_OBJECT_EXISTS HRESULT for retornado, você saberá que ele já existe no diretório. Como alternativa, você pode pesquisar o diretório para o usuário e, em seguida, adicionar apenas o usuário se a pesquisa falhar.

Esse uso de erros para o controle de fluxo normal é um antipadrão de desempenho no contexto do CLR. O tratamento de erros no CLR é feito com tratamento de exceção estruturado. As exceções gerenciadas são muito baratas até você jogá-las. No CLR, quando uma exceção é gerada, uma caminhada de pilha é necessária para localizar um manipulador de exceção apropriado para a exceção gerada. Andar em pilha é uma operação cara. As exceções devem ser usadas como o nome indica; em circunstâncias excepcionais ou inesperadas.

**HINT **Considere retornar um resultado enumerado para os resultados esperados, em vez de gerar uma exceção, para métodos críticos de desempenho.

**HINT **Há vários Contadores de Desempenho de Exceções clr do .NET que informarão quantas exceções estão sendo geradas em seu aplicativo.

**HINT **Se você estiver usando VB.NET usar exceções em vez de On Error Goto; o objeto de erro é um custo desnecessário.

Threading e sincronização

O CLR expõe recursos avançados de threading e sincronização, incluindo a capacidade de criar seus próprios threads, um pool de threads e vários primitivos de sincronização. Antes de aproveitar o suporte ao threading no CLR, você deve considerar cuidadosamente o uso de threads. Tenha em mente que adicionar threads pode realmente reduzir sua taxa de transferência em vez de aumentá-la, e você pode ter certeza de que isso aumentará sua utilização de memória. Em aplicativos de servidor que serão executados em computadores com vários processadores, a adição de threads pode melhorar significativamente a taxa de transferência paralelizando a execução (embora isso dependa de quanta contenção de bloqueio está acontecendo, por exemplo, serialização de execução) e em aplicativos cliente, adicionar um thread para mostrar atividade e/ou progresso pode melhorar o desempenho percebido (a um pequeno custo de taxa de transferência).

Se os threads em seu aplicativo não forem especializados para uma tarefa específica ou tiverem um estado especial associado a eles, você deverá considerar o uso do pool de threads. Se você tiver usado o Pool de Threads do Win32 no passado, o Pool de Threads do CLR será muito familiar para você. Há uma única instância do Pool de Threads por processo gerenciado. O Pool de Threads é inteligente sobre o número de threads que ele cria e se ajustará de acordo com a carga no computador.

O threading não pode ser discutido sem discutir a sincronização; todos os ganhos de taxa de transferência que o multithreading pode dar ao aplicativo podem ser negados pela lógica de sincronização mal gravada. A granularidade dos bloqueios pode afetar significativamente a taxa de transferência geral do aplicativo, tanto devido ao custo de criar e gerenciar o bloqueio quanto ao fato de que os bloqueios podem potencialmente serializar a execução. Usarei o exemplo de como tentar adicionar um nó a uma árvore para ilustrar esse ponto. Se a árvore for uma estrutura de dados compartilhada, por exemplo, vários threads precisarão acessá-la durante a execução do aplicativo e você precisará sincronizar o acesso à árvore. Você pode optar por bloquear a árvore inteira ao adicionar um nó, o que significa que você só incorrerá no custo de criar um único bloqueio, mas outros threads que tentam acessar a árvore provavelmente serão bloqueados. Esse seria um exemplo de um bloqueio de granularidade grosseira. Como alternativa, você pode bloquear cada nó enquanto atravessa a árvore, o que significaria incorrer no custo de criar um bloqueio por nó, mas outros threads não seriam bloqueados, a menos que tentassem acessar o nó específico que você havia bloqueado. Este é um exemplo de um bloqueio refinado. Provavelmente, uma granularidade mais apropriada de bloqueio seria bloquear apenas a subárvore na qual você está operando. Observe que, neste exemplo, você provavelmente usaria um bloqueio compartilhado (RWLock), pois vários leitores devem ser capazes de obter acesso ao mesmo tempo.

A maneira mais simples e de alto desempenho de fazer operações sincronizadas é usar a classe System.Threading.Interlocked. A classe Interlocked expõe várias operações atômicas de baixo nível: Increment,Decrement, Exchange e CompareExchange.

Usando a classe System.Threading.Interlocked em C#

using System.Threading;
//...
public class MyClass
{
   void MyClass() //Constructor
   {
      //Increment a global instance counter atomically
      Interlocked.Increment(ref MyClassInstanceCounter);
   }

   ~MyClass() //Finalizer
   {
      //Decrement a global instance counter atomically
      Interlocked.Decrement(ref MyClassInstanceCounter);
      //... 
   }
   //...
}

Provavelmente, o mecanismo de sincronização mais usado é a seção Monitor ou crítica. Um bloqueio monitor pode ser usado diretamente ou usando o lock palavra-chave em C#. O lock palavra-chave sincroniza o acesso, para o objeto fornecido, a um bloco de código específico. Um bloqueio monitor que é bastante pouco contestado é relativamente barato do ponto de vista do desempenho, mas se torna mais caro se for altamente contestado.

O palavra-chave de bloqueio do C#

//Thread will attempt to obtain the lock
//and block until it does
lock(mySharedObject)
{
   //A thread will only be able to execute the code
   //within this block if it holds the lock
}//Thread releases the lock

O RWLock fornece um mecanismo de bloqueio compartilhado: por exemplo, "leitores" podem compartilhar o bloqueio com outros "leitores", mas um "gravador" não pode. Nos casos em que isso é aplicável, o RWLock pode resultar em uma taxa de transferência melhor do que usar um Monitor, o que só permitiria que um único leitor ou gravador obtémsse o bloqueio por vez. O namespace System.Threading também inclui a classe Mutex. Um Mutex é um primitivo de sincronização que permite a sincronização entre processos. Lembre-se de que isso é significativamente mais caro do que uma seção crítica e só deve ser usado no caso em que a sincronização entre processos é necessária.

Reflexão

Reflexão é um mecanismo fornecido pelo CLR, que permite obter informações de tipo programaticamente em runtime. A reflexão depende muito dos metadados, que são inseridos em assemblies gerenciados. Muitas APIs de reflexão exigem pesquisa e análise dos metadados, que são operações caras.

As APIs de reflexão podem ser agrupadas em três buckets de desempenho; comparação de tipos, enumeração de membro e invocação de membro. Cada um desses buckets fica progressivamente mais caro. As operações de comparação de tipos — nesse caso, typeof em C#, GetType, is, IsInstanceOfType e assim por diante — são as mais baratas das APIs de reflexão, embora não sejam baratas de forma alguma. As enumerações de membro permitem inspecionar programaticamente os métodos, propriedades, campos, eventos, construtores e assim por diante de uma classe. Um exemplo de onde eles podem ser usados está em cenários de tempo de design, nesse caso, enumerando propriedades de Controles da Web da Alfândega para o Navegador de Propriedades no Visual Studio. As APIs de reflexão mais caras são aquelas que permitem invocar dinamicamente os membros de uma classe ou emitir dinamicamente, JIT e executar um método. Certamente há cenários de associação tardia em que o carregamento dinâmico de assemblies, instanciações de tipos e invocações de método são necessários, mas esse acoplamento flexível requer uma compensação explícita de desempenho. Em geral, as APIs de reflexão devem ser evitadas em caminhos de código sensíveis ao desempenho. Observe que, embora você não use diretamente a reflexão, uma API que você usa pode usá-la. Portanto, lembre-se também do uso transitivo das APIs de reflexão.

Associação tardia

Chamadas com associação tardia são um exemplo de um recurso que usa Reflexão nos covers. Os Basic.NET visuais e JScript.NET têm suporte para chamadas com associação tardia. Por exemplo, você não precisa declarar uma variável antes de seu uso. Objetos com associação tardia são, na verdade, do tipo objeto e Reflection é usado para converter o objeto para o tipo correto em runtime. Uma chamada de associação tardia é ordens de magnitude mais lentas do que uma chamada direta. A menos que você precise especificamente de um comportamento de associação tardia, evite seu uso em caminhos de código críticos ao desempenho.

DICA Se você estiver usando VB.NET e não precisar explicitamente de associação tardia, poderá dizer ao compilador para não permitir isso incluindo o Option Explicit On e Option Strict On na parte superior dos arquivos de origem. Essas opções forçam você a declarar e digitar fortemente suas variáveis e desativa a conversão implícita.

Segurança

A segurança é uma parte necessária e integral do CLR e tem um custo de desempenho associado a ele. Caso o código seja Totalmente Confiável e a política de segurança seja o padrão, a segurança deve ter um pequeno impacto na taxa de transferência e no tempo de inicialização do aplicativo. O código parcialmente confiável, por exemplo, o código da Internet ou da Zona da Intranet, ou restringir o Conjunto de Concessões do MyComputer aumentará o custo de desempenho da segurança.

Interoperabilidade COM e invocação de plataforma

Interoperabilidade COM e Invocação de Plataforma expõem APIs nativas ao código gerenciado de maneira quase transparente; chamar a maioria das APIs nativas normalmente não requer nenhum código especial, embora possa exigir alguns cliques do mouse. Como você pode esperar, há um custo associado à chamada de código nativo do código gerenciado e vice-versa. Há dois componentes para esse custo: um custo fixo associado a fazer as transições entre código nativo e gerenciado e um custo variável associado a qualquer marshalling de argumentos e valores retornados que possam ser necessários. A contribuição fixa para o custo de Interoperabilidade COM e P/Invoke é pequena: normalmente menos de 50 instruções. O custo de marshaling de e para tipos gerenciados dependerá de quão diferentes as representações estão em ambos os lados do limite. Os tipos que exigem uma quantidade significativa de transformação serão mais caros. Por exemplo, todas as cadeias de caracteres no CLR são cadeias de caracteres Unicode. Se você estiver chamando uma API win32 por meio de P/Invoke que espera uma matriz de caracteres ANSI, cada caractere na cadeia de caracteres deve ser restringido. No entanto, se uma matriz de inteiros gerenciada estiver sendo passada para onde uma matriz de inteiros nativa é esperada, nenhum marshalling será necessário.

Como há um custo de desempenho associado à chamada de código nativo, você deve garantir que o custo seja justificado. Se você for fazer uma chamada nativa, verifique se o trabalho que a chamada nativa justifica o custo de desempenho associado à chamada , mantenha os métodos "volumosos" em vez de "tagarelas". Uma boa maneira de medir o custo de uma chamada nativa é medir o desempenho de um método nativo que não usa argumentos e não tem nenhum valor retornado e, em seguida, medir o desempenho do método nativo que você deseja chamar. A diferença lhe dará uma indicação do custo de marshalling.

DICA Faça chamadas de Interoperabilidade COM "Chunky" e P/Invoke em vez de chamadas "Chatty" e verifique se o custo de fazer a chamada é justificado pela quantidade de trabalho que a chamada faz.

Observe que não há modelos de threading associados a threads gerenciados. Quando você vai fazer uma chamada de Interoperabilidade COM, precisa verificar se o thread no qual a chamada será feita é inicializado para o modelo de threading COM correto. Normalmente, isso é feito usando MTAThreadAttribute e STAThreadAttribute (embora também possa ser feito programaticamente).

Contadores de desempenho

Vários contadores de desempenho do Windows são expostos para o CLR do .NET. Esses contadores de desempenho devem ser a arma preferida de um desenvolvedor ao diagnosticar pela primeira vez um problema de desempenho ou ao tentar identificar as características de desempenho de um aplicativo gerenciado. Já mencionei alguns dos contadores relacionados ao gerenciamento de memória e exceções. Há contadores de desempenho para quase todos os aspectos do CLR e .NET Framework. Esses contadores de desempenho estão sempre disponíveis e não são invasivos; eles têm baixa sobrecarga e não alteram as características de desempenho do seu aplicativo.

Outras Ferramentas

Além dos Contadores de Desempenho e do CLR Profiler, você desejará usar um criador de perfil convencional para verificar quais métodos em seu aplicativo estão levando mais tempo e sendo chamados com mais frequência. Esses serão os métodos que você otimiza primeiro. Vários profilers comerciais estão disponíveis para dar suporte ao código gerenciado, incluindo o DevPartner Studio Professional Edition 7.0 da Compuware e do VTune™ Performance Analyzer 7.0 da Intel®. O Compuware também produz um criador de perfil gratuito para código gerenciado chamado DevPartner Profiler Community Edition.

Conclusão

Este artigo apenas inicia o exame do CLR e da .NET Framework de uma perspectiva de desempenho. Há muitos outros aspectos da arquitetura do CLR e do .NET Framework que afetarão o desempenho do aplicativo. A melhor orientação que posso dar a qualquer desenvolvedor é não fazer suposições sobre o desempenho da plataforma que seu aplicativo está direcionando e as APIs que você está usando. Meça tudo!

Malabarismo feliz.

Recursos