Considerações de desempenho para tecnologias de Run-Time no .NET Framework

 

Emmanuel Schanzer
Microsoft Corporation

Agosto de 2001

Resumo: Este artigo inclui uma pesquisa de várias tecnologias no trabalho no mundo gerenciado e uma explicação técnica de como elas afetam o desempenho. Saiba mais sobre o funcionamento da coleta de lixo, JIT, comunicação remota, ValueTypes, segurança e muito mais. (27 páginas impressas)

Sumário

Visão geral
Coleta de lixo
Pool de Threads
O JIT
AppDomains
Segurança
Comunicação remota
ValueTypes
Recursos adicionais
Apêndice: hospedando o tempo de execução do servidor

Visão geral

O tempo de execução do .NET apresenta várias tecnologias avançadas voltadas para segurança, facilidade de desenvolvimento e desempenho. Como desenvolvedor, é importante entender cada uma das tecnologias e usá-las efetivamente em seu código. As ferramentas avançadas fornecidas pelo tempo de execução facilitam a criação de um aplicativo robusto, mas fazer com que esse aplicativo voe rapidamente é (e sempre foi) responsabilidade do desenvolvedor.

Este white paper deve fornecer uma compreensão mais profunda das tecnologias em funcionamento no .NET e ajudá-lo a ajustar seu código quanto à velocidade. Observação: esta não é uma folha de especificações. Já há muitas informações técnicas sólidas por aí. O objetivo aqui é fornecer as informações com uma forte inclinação para o desempenho e pode não responder a todas as perguntas técnicas que você tem. É recomendável procurar mais na Biblioteca Online do MSDN se você não encontrar as respostas que procura aqui.

Vou abordar as tecnologias a seguir, fornecendo uma visão geral de alto nível de sua finalidade e por que elas afetam o desempenho. Em seguida, vou me aprofundar em alguns detalhes de implementação de nível inferior e usar o código de exemplo para ilustrar as maneiras de obter velocidade de cada tecnologia.

Coleta de lixo

Noções básicas

A GC (coleta de lixo) libera o programador de erros comuns e difíceis de depurar liberando memória para objetos que não são mais usados. O caminho geral seguido para o tempo de vida de um objeto é o seguinte, no código gerenciado e nativo:

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object   
delete a;               // Tear down the state of the object, clean up
                        // and free the memory for that object

No código nativo, você precisa fazer todas essas coisas por conta própria. A falta das fases de alocação ou limpeza pode resultar em um comportamento totalmente imprevisível que é difícil de depurar e esquecer de liberar objetos pode resultar em vazamentos de memória. O caminho para alocação de memória no CLR (Common Language Runtime) está muito próximo do caminho que acabamos de abordar. Se adicionarmos as informações específicas do GC, acabaremos com algo muito semelhante.

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object (it is strongly reachable)
a = null;               // A becomes unreachable (out of scope, nulled, etc)
                        // Eventually a collection occurs, and a's resources
                        // are torn down and the memory is freed

Até que o objeto possa ser liberado, as mesmas etapas são executadas em ambos os mundos. No código nativo, você precisa se lembrar de liberar o objeto quando terminar de usá-lo. No código gerenciado, depois que o objeto não estiver mais acessível, o GC poderá recolhê-lo. É claro que, se o recurso exigir atenção especial para ser liberado (por exemplo, fechar um soquete), o GC poderá precisar de ajuda para fechá-lo corretamente. O código que você escreveu antes para limpo um recurso antes de liberá-lo ainda se aplica, na forma de métodos Dispose() e Finalize(). Falarei sobre as diferenças entre esses dois mais tarde.

Se você mantiver um ponteiro para um recurso ao redor, o GC não terá como saber se você pretende usá-lo no futuro. O que isso significa é que todas as regras que você usou no código nativo para liberar objetos explicitamente ainda se aplicam, mas na maioria das vezes o GC manipulará tudo para você. Em vez de se preocupar com o gerenciamento de memória cem por cento do tempo, você só precisa se preocupar com isso cerca de 5% do tempo.

O Coletor de Lixo CLR é um coletor geracional, de marca e compacto. Ele segue vários princípios que permitem alcançar um excelente desempenho. Primeiro, há a noção de que objetos de curta duração tendem a ser menores e são acessados com frequência. O GC divide o grafo de alocação em vários sub grafos, chamados gerações, que permitem que ele gaste o menor tempo possível coletando*.* Gen 0 contém objetos jovens e usados com frequência. Isso também tende a ser o menor e leva cerca de 10 milissegundos para coletar. Como o GC pode ignorar as outras gerações durante essa coleção, ele fornece um desempenho muito maior. G1 e G2 são para objetos maiores e mais antigos e são coletados com menos frequência. Quando ocorre uma coleção G1, G0 também é coletado. Uma coleção G2 é uma coleção completa e é a única vez que o GC atravessa todo o grafo. Ele também faz uso inteligente dos caches de CPU, que podem ajustar o subsistema de memória para o processador específico no qual ele é executado. Essa é uma otimização que não está disponível facilmente na alocação nativa e pode ajudar seu aplicativo a melhorar o desempenho.

Quando uma coleção acontece?

Quando uma alocação de tempo é feita, o GC verifica se uma coleção é necessária. Ele examina o tamanho da coleção, a quantidade de memória restante e os tamanhos de cada geração e, em seguida, usa uma heurística para tomar a decisão. Até que ocorra uma coleção, a velocidade de alocação de objeto geralmente é tão rápida (ou mais rápida) do que C ou C++.

O que acontece quando ocorre uma coleção?

Vamos percorrer as etapas que um coletor de lixo executa durante uma coleta. O GC mantém uma lista de raízes, que apontam para o heap de GC. Se um objeto estiver ativo, haverá uma raiz em sua localização no heap. Objetos no heap também podem apontar uns para os outros. Esse grafo de ponteiros é o que o GC deve pesquisar para liberar espaço. A ordem dos eventos é a seguinte:

  1. O heap gerenciado mantém todo o espaço de alocação em um bloco contíguo e, quando esse bloco é menor que o valor solicitado, o GC é chamado.

  2. O GC segue cada raiz e todos os ponteiros a seguir, mantendo uma lista dos objetos que não podem ser acessados.

  3. Cada objeto não acessível de nenhuma raiz é considerado colecionável e é marcado para coleção.

    Figura 1. Antes da Coleção: Observe que nem todos os blocos podem ser acessados a partir de raízes!

  4. Remover objetos do grafo de acessibilidade torna a maioria dos objetos colecionáveis. No entanto, alguns recursos precisam ser tratados especialmente. Ao definir um objeto, você tem a opção de escrever um método Dispose() ou um método Finalize() (ou ambos). Falarei sobre as diferenças entre os dois e quando usá-los mais tarde.

  5. A etapa final em uma coleção é a fase de compactação. Todos os objetos que estão em uso são movidos para um bloco contíguo e todos os ponteiros e raízes são atualizados.

  6. Compactando os objetos dinâmicos e atualizando o endereço inicial do espaço livre, o GC afirma que todo o espaço livre é contíguo. Se houver espaço suficiente para alocar o objeto, o GC retornará o controle para o programa. Caso contrário, ele gera um OutOfMemoryException.

    Figura 2. Após a Coleta: os blocos acessíveis foram compactados. Mais espaço livre!

Para obter mais informações técnicas sobre gerenciamento de memória, consulte Capítulo 3 de Aplicativos de Programação para Microsoft Windows por Jeffrey Richter (Microsoft Press, 1999).

Limpeza de Objeto

Alguns objetos exigem tratamento especial antes que seus recursos possam ser retornados. Alguns exemplos desses recursos são arquivos, soquetes de rede ou conexões de banco de dados. Simplesmente liberar a memória no heap não será suficiente, pois você deseja que esses recursos sejam fechados normalmente. Para executar a limpeza de objetos, você pode escrever um método Dispose(), um método Finalize() ou ambos.

Um método Finalize():

  • É chamado pelo GC
  • Não há garantia de ser chamado em qualquer ordem ou em um momento previsível
  • Depois de ser chamado, libera memória após o próximo GC
  • Mantém todos os objetos filho ativos até o próximo GC

Um método Dispose():

  • É chamado pelo programador
  • É ordenado e agendado pelo programador
  • Retorna recursos após a conclusão do método

Objetos gerenciados que contêm apenas recursos gerenciados não exigem esses métodos. Seu programa provavelmente usará apenas alguns recursos complexos, e as chances são de que você saiba o que eles são e quando precisar deles. Se você souber essas duas coisas, não há razão para depender de finalizadores, pois você pode fazer a limpeza manualmente. Há vários motivos pelos quais você deseja fazer isso e todos eles têm a ver com a fila do finalizador.

No GC, quando um objeto que tem um finalizador é marcado como colecionável, ele e todos os objetos para os quais ele aponta são colocados em uma fila especial. Um thread separado percorre essa fila, chamando o método Finalize() de cada item na fila. O programador não tem controle sobre esse thread ou a ordem dos itens colocados na fila. O GC pode retornar o controle para o programa, sem ter finalizado nenhum objeto na fila. Esses objetos podem permanecer na memória, escondidos na fila por muito tempo. As chamadas para finalizar são feitas automaticamente e não há impacto direto no desempenho da própria chamada. No entanto, o modelo não determinístico para finalização pode definitivamente ter outras consequências indiretas:

  • Em um cenário em que você tem recursos que precisam ser liberados em um momento específico, você perde o controle com finalizadores. Digamos que você tenha um arquivo aberto e ele precisa ser fechado por motivos de segurança. Mesmo quando você definir o objeto como nulo e forçar um GC imediatamente, o arquivo permanecerá aberto até que seu método Finalize() seja chamado e você não tem ideia de quando isso pode acontecer.
  • N objetos que exigem descarte em uma determinada ordem podem não ser tratados corretamente.
  • Um objeto enorme e seus filhos podem ocupar muita memória, exigir coleções adicionais e prejudicar o desempenho. Esses objetos podem não ser coletados por muito tempo.
  • Um objeto pequeno a ser finalizado pode ter ponteiros para recursos grandes que podem ser liberados a qualquer momento. Esses objetos não serão liberados até que o objeto a ser finalizado seja cuidado, criando pressão de memória desnecessária e forçando coleções frequentes.

O diagrama de estado na Figura 3 ilustra os diferentes caminhos que seu objeto pode tomar em termos de finalização ou descarte.

Figura 3. Caminhos de descarte e finalização que um objeto pode tomar

Como você pode ver, a finalização adiciona várias etapas ao tempo de vida do objeto. Se você descartar um objeto por conta própria, o objeto poderá ser coletado e a memória retornada para você no próximo GC. Quando a finalização precisa ocorrer, você precisa aguardar até que o método real seja chamado. Como você não recebe nenhuma garantia sobre quando isso acontece, você pode ter muita memória amarrada e ficar à mercê da fila de finalização. Isso pode ser extremamente problemático se o objeto estiver conectado a uma árvore inteira de objetos e todos eles ficarem na memória até que ocorra a finalização.

Escolhendo qual coletor de lixo usar

O CLR tem dois GCs diferentes: Estação de Trabalho (mscorwks.dll) e Servidor (mscorsvr.dll). Ao executar no modo estação de trabalho, a latência é mais uma preocupação do que o espaço ou a eficiência. Um servidor com vários processadores e clientes conectados por uma rede pode pagar alguma latência, mas a taxa de transferência agora é uma prioridade máxima. Em vez de colocar esses dois cenários em um único esquema de GC, a Microsoft incluiu dois coletores de lixo que são adaptados para cada situação.

GC do servidor:

  • Multiprocessador (MP) Escalonável, Paralelo
  • Um thread GC por CPU
  • Programa pausado durante a marcação

GC da estação de trabalho:

  • Minimiza pausas executando simultaneamente durante coleções completas

O GC do servidor foi projetado para a taxa de transferência máxima e é dimensionado com um desempenho muito alto. A fragmentação de memória em servidores é um problema muito mais grave do que nas estações de trabalho, tornando a coleta de lixo uma proposta atraente. Em um cenário uniprocessador, ambos os coletores funcionam da mesma maneira: modo de estação de trabalho, sem coleção simultânea. Em um computador MP, o GC da Estação de Trabalho usa o segundo processador para executar a coleção simultaneamente, minimizando os atrasos e diminuindo a taxa de transferência. O GC do Servidor usa vários heaps e threads de coleção para maximizar a taxa de transferência e dimensionar melhor.

Você pode escolher qual GC usar ao hospedar o tempo de execução. Ao carregar o tempo de execução em um processo, especifique qual coletor usar. O carregamento da API é discutido no Guia do Desenvolvedor do .NET Framework. Para obter um exemplo de um programa simples que hospeda o tempo de execução e seleciona o GC do servidor, dê uma olhada no Apêndice.

Mito: Coleta de lixo é sempre mais lenta do que fazer isso manualmente

Na verdade, até que uma coleção seja chamada, o GC é muito mais rápido do que fazê-lo manualmente em C. Isso surpreende muitas pessoas, então vale a pena alguma explicação. Em primeiro lugar, observe que encontrar espaço livre ocorre em tempo constante. Como todo o espaço livre é contíguo, o GC simplesmente segue o ponteiro e verifica se há espaço suficiente. Em C, uma chamada para malloc() normalmente resulta em uma pesquisa de uma lista vinculada de blocos gratuitos. Isso pode ser demorado, especialmente se o heap estiver muito fragmentado. Para piorar as coisas, várias implementações do tempo de execução C bloqueiam o heap durante este procedimento. Depois que a memória for alocada ou usada, a lista deverá ser atualizada. Em um ambiente coletado por lixo, a alocação é gratuita e a memória é liberada durante a coleta. Programadores mais avançados reservarão grandes blocos de memória e manipularão a alocação dentro desse bloco. O problema com essa abordagem é que a fragmentação de memória se torna um grande problema para os programadores e os força a adicionar muita lógica de manipulação de memória aos seus aplicativos. No final, um coletor de lixo não adiciona muita sobrecarga. A alocação é mais rápida ou rápida e a compactação é tratada automaticamente, liberando programadores para se concentrarem em seus aplicativos.

No futuro, os coletores de lixo podem executar outras otimizações que o tornam ainda mais rápido. A identificação de ponto quente e o melhor uso do cache são possíveis e podem fazer enormes diferenças de velocidade. Um GC mais inteligente poderia empacotar páginas com mais eficiência, minimizando assim o número de buscas de página que ocorrem durante a execução. Tudo isso poderia tornar um ambiente coletado de lixo mais rápido do que fazer as coisas manualmente.

Algumas pessoas podem se perguntar por que a GC não está disponível em outros ambientes, como C ou C++. A resposta são tipos. Essas linguagens permitem a conversão de ponteiros para qualquer tipo, tornando extremamente difícil saber a que um ponteiro se refere. Em um ambiente gerenciado como o CLR, podemos garantir o suficiente sobre os ponteiros para tornar a GC possível. O mundo gerenciado também é o único lugar em que podemos interromper com segurança a execução de thread para executar um GC: no C++ essas operações são inseguras ou muito limitadas.

Ajuste de velocidade

A maior preocupação para um programa no mundo gerenciado é a retenção de memória. Alguns dos problemas que você encontrará em ambientes não gerenciados não são um problema no mundo gerenciado: vazamentos de memória e ponteiros pendentes não são um grande problema aqui. Em vez disso, os programadores precisam ter cuidado ao deixar os recursos conectados quando não precisarem mais deles.

A heurística mais importante para o desempenho também é a mais fácil de aprender para programadores que estão acostumados a escrever código nativo: controlar as alocações a serem feitas e liberá-las quando terminar. O GC não tem como saber que você não usará uma cadeia de caracteres de 20 KB que você criou se fizer parte de um objeto que está sendo mantido por perto. Suponha que você tenha esse objeto escondido em um vetor em algum lugar e nunca mais pretenda usar essa cadeia de caracteres novamente. Definir o campo como nulo permitirá que o GC colete esses 20 KB mais tarde, mesmo que você ainda precise do objeto para outras finalidades. Se você não precisar mais do objeto, verifique se não está mantendo referências a ele. (Assim como no código nativo.) Para objetos menores, isso é menos um problema. Qualquer programador familiarizado com o gerenciamento de memória no código nativo não terá nenhum problema aqui: todas as mesmas regras de bom senso se aplicam. Você não precisa ser tão paranóico com eles.

A segunda preocupação de desempenho importante lida com a limpeza de objetos. Como mencionei anteriormente, a finalização tem impactos profundos no desempenho. O exemplo mais comum é o de um manipulador gerenciado para um recurso não gerenciado: você precisa implementar algum tipo de método de limpeza e é aí que o desempenho se torna um problema. Se você depender da finalização, abra-se para os problemas de desempenho que listei anteriormente. Outra coisa a ter em mente é que o GC não está ciente da pressão de memória no mundo nativo, então você pode estar usando uma tonelada de recursos não gerenciados apenas mantendo um ponteiro ao redor no heap gerenciado. Um único ponteiro não leva muita memória, portanto, pode demorar um pouco até que uma coleção seja necessária. Para contornar esses problemas de desempenho, enquanto ainda o joga com segurança quando se trata de retenção de memória, você deve escolher um padrão de design com o qual trabalhar para todos os objetos que exigem limpeza especial.

O programador tem quatro opções ao lidar com a limpeza de objetos:

  1. Implementar Ambos

    Esse é o design recomendado para limpeza de objetos. Esse é um objeto com alguma combinação de recursos não gerenciados e não gerenciados. Um exemplo seria System.Windows.Forms.Control. Isso tem um recurso não gerenciado (HWND) e recursos potencialmente gerenciados (DataConnection etc.). Se você não tiver certeza de quando usar recursos não gerenciados, poderá abrir o manifesto do programa em ILDASM`` e marcar para referências a bibliotecas nativas. Outra alternativa é usar vadump.exe para ver quais recursos são carregados junto com seu programa. Ambos podem fornecer insights sobre que tipo de recursos nativos você usa.

    O padrão abaixo fornece aos usuários uma única maneira recomendada em vez de substituir a lógica de limpeza (substituir Dispose(bool)). Isso fornece flexibilidade máxima, bem como catch-all apenas no caso de Dispose() nunca ser chamado. A combinação de velocidade máxima e flexibilidade, bem como a abordagem de rede de segurança fazem deste o melhor design a ser usado.

    Exemplo:

    public class MyClass : IDisposable {
      public void Dispose() {
        Dispose(true);
        GC.SuppressFinalizer(this);
      }
      protected virtual void Dispose(bool disposing) {
        if (disposing) {
          ...
        }
          ...
      }
      ~MyClass() {
        Dispose(false);
      }
    }
    
  2. Implementar Dispose() Only

    É quando um objeto tem apenas recursos gerenciados e você deseja garantir que sua limpeza seja determinística. Um exemplo desse objeto é System.Web.UI.Control.

    Exemplo:

    public class MyClass : IDisposable {
      public virtual void Dispose() {
        ...
      }
    
  3. Implementar Somente Finalize()

    Isso é necessário em situações extremamente raras, e eu recomendo fortemente contra isso. A implicação de um objeto Finalize() somente é que o programador não tem ideia de quando o objeto será coletado, mas está usando um recurso complexo o suficiente para exigir limpeza especial. Essa situação nunca deve ocorrer em um projeto bem projetado e, se você se encontrar nele, deverá voltar e descobrir o que deu errado.

    Exemplo:

    public class MyClass {
      ...
      ~MyClass() {
        ...
      }
    
  4. Implementar Nenhum

    Isso é para um objeto gerenciado que aponta apenas para outros objetos gerenciados que não são descartáveis nem para serem finalizados.

Recomendação

As recomendações para lidar com o gerenciamento de memória devem ser familiares: liberar objetos quando terminar de usá-los e ficar atento para deixar ponteiros para objetos. Quando se trata de limpeza de objeto, implemente um método Finalize() e Dispose() para objetos com recursos não gerenciados. Isso impedirá um comportamento inesperado posteriormente e imporá boas práticas de programação

A desvantagem aqui é que você força as pessoas a ter que chamar Dispose(). Não há perda de desempenho aqui, mas algumas pessoas podem achar frustrante ter que pensar em descartar seus objetos. No entanto, acho que vale a pena o agravamento usar um modelo que faça sentido. Além disso, isso força as pessoas a ficarem mais atentas aos objetos alocados, pois não podem confiar cegamente no GC para sempre cuidar deles. Para programadores provenientes de uma tela de fundo C ou C++, forçar uma chamada para Dispose() provavelmente será benéfico, pois é o tipo de coisa com que eles estão mais familiarizados.

Dispose() deve ter suporte em objetos que se mantêm em recursos não gerenciados em qualquer lugar na árvore de objetos abaixo dele; no entanto, Finalize() só precisa ser colocado nos objetos que estão especificamente segurando esses recursos, como um identificador do sistema operacional ou alocação de memória não gerenciada. Sugiro a criação de pequenos objetos gerenciados como "wrappers" para implementar Finalize(), além de dar suporte a Dispose(),, que seria chamado por Dispose()do objeto pai. Como os objetos pai não têm um finalizador, toda a árvore de objetos não sobreviverá a uma coleção, independentemente de Dispose() ter sido ou não chamado.

Uma boa regra geral para finalizadores é usá-los somente no objeto mais primitivo que requer finalização. Suponha que eu tenha um grande recurso gerenciado que inclua uma conexão de banco de dados: eu tornaria possível que a conexão em si fosse finalizada, mas tornaria o restante do objeto descartável. Dessa forma, posso chamar Dispose() e liberar as partes gerenciadas do objeto imediatamente, sem precisar aguardar a conexão ser finalizada. Lembre-se: use Finalize() somente onde for necessário, quando for necessário.

Nota Programadores C e C++: a semântica do destruidor em C# cria um finalizador, não um método de descarte!

Pool de Threads

Noções básicas

O pool de threads do CLR é semelhante ao pool de threads NT de várias maneiras e não requer quase nenhuma nova compreensão por parte do programador. Ele tem um thread de espera, que pode manipular os blocos para outros threads e notificá-los quando precisarem retornar, liberando-os para fazer outro trabalho. Ele pode gerar novos threads e bloquear outros para otimizar a utilização da CPU em tempo de execução, garantindo que a maior quantidade de trabalho útil seja feita. Ele também recicla threads quando eles são concluídos, iniciando-os novamente sem a sobrecarga de matar e gerar novos. Esse é um aumento substancial de desempenho em relação ao tratamento manual de threads, mas não é um catch-all. Saber quando usar o pool de threads é essencial ao ajustar um aplicativo encadeado.

O que você sabe do pool de threads NT:

  • O pool de threads manipulará a criação e a limpeza de threads.
  • Ele fornece uma porta de conclusão para threads de E/S (somente plataformas NT).
  • O retorno de chamada pode ser associado a arquivos ou outros recursos do sistema.
  • ApIs de temporizador e espera estão disponíveis.
  • O pool de threads determina quantos threads devem estar ativos usando heurísticas como atraso desde a última injeção, número de threads atuais e tamanho da fila.
  • Threads se alimentam de uma fila compartilhada.

O que há de diferente no .NET:

  • Ele está ciente do bloqueio de threads no código gerenciado (por exemplo, devido à coleta de lixo, espera gerenciada) e pode ajustar sua lógica de injeção de thread adequadamente.
  • Não há garantia de serviço para threads individuais.

Quando lidar com threads por conta própria

Usar o pool de threads efetivamente está intimamente vinculado a saber o que você precisa de seus threads. Se você precisar de uma garantia de serviço, precisará gerenciá-lo por conta própria. Para a maioria dos casos, o uso do pool fornecerá o desempenho ideal. Se você tiver restrições rígidas e precisar de um controle rígido de seus threads, provavelmente fará mais sentido usar threads nativos de qualquer maneira, portanto, tenha cuidado com o tratamento de threads gerenciados por conta própria. Se você decidir escrever código gerenciado e lidar com o threading por conta própria, certifique-se de não gerar threads por conexão: isso só prejudicará o desempenho. Como regra geral, você só deve optar por lidar com threads por conta própria no mundo gerenciado em cenários muito específicos em que há uma tarefa grande e demorada que raramente é feita. Um exemplo pode ser preencher um cache grande em segundo plano ou gravar um arquivo grande em disco.

Ajustando a velocidade

O pool de threads define um limite de quantos threads devem estar ativos e, se muitos deles bloquearem, o pool passará fome. Idealmente, você deve usar o pool de threads para threads de curta duração e sem bloqueio. Em aplicativos de servidor, você deseja responder a cada solicitação de forma rápida e eficiente. Se você criar um novo thread para cada solicitação, lidará com muita sobrecarga. A solução é reciclar seus threads, tendo o cuidado de limpo e retornar o estado de cada thread após a conclusão. Esses são os cenários em que o pool de threads é uma grande vitória de desempenho e design e onde você deve fazer um bom uso da tecnologia. O pool de threads manipula a limpeza de estado para você e garante que o número ideal de threads esteja em uso em um determinado momento. Em outras situações, pode fazer mais sentido lidar com threading por conta própria.

Embora o CLR possa usar a segurança de tipo para fazer garantias sobre processos para garantir que o AppDomains possa compartilhar o mesmo processo, essa garantia não existe com threads. O programador é responsável por escrever threads bem comportados e todo o seu conhecimento do código nativo ainda se aplica.

Abaixo temos um exemplo de um aplicativo simples que aproveita o pool de threads. Ele cria vários threads de trabalho e, em seguida, faz com que eles executem uma tarefa simples antes de fechá-los. Tirei algumas verificações de erro, mas esse é o mesmo código que pode ser encontrado na pasta SDK do Framework em "Samples\Threading\Threadpool". Neste exemplo, temos algum código que cria um item de trabalho simples e usa o threadpool para que vários threads manipulem esses itens sem que o programador precise gerenciá-los. Confira o arquivo de ReadMe.html para obter mais informações.

using System;
using System.Threading;

public class SomeState{
  public int Cookie;
  public SomeState(int iCookie){
    Cookie = iCookie;
  }
};


public class Alpha{
  public int [] HashCount;
  public ManualResetEvent eventX;
  public static int iCount = 0;
  public static int iMaxCount = 0;
  public Alpha(int MaxCount) {
    HashCount = new int[30];
    iMaxCount = MaxCount;
  }


   //   The method that will be called when the Work Item is serviced
   //   on the Thread Pool
   public void Beta(Object state){
     Console.WriteLine(" {0} {1} :", 
               Thread.CurrentThread.GetHashCode(), ((SomeState)state).Cookie);
     Interlocked.Increment(ref HashCount[Thread.CurrentThread.GetHashCode()]);

     //   Do some busy work
     int iX = 10000;
     while (iX > 0){ iX--;}
     if (Interlocked.Increment(ref iCount) == iMaxCount) {
       Console.WriteLine("Setting EventX ");
       eventX.Set();
     }
  }
};

public class SimplePool{
  public static int Main(String[] args)   {
    Console.WriteLine("Thread Simple Thread Pool Sample");
    int MaxCount = 1000;
    ManualResetEvent eventX = new ManualResetEvent(false);
    Console.WriteLine("Queuing {0} items to Thread Pool", MaxCount);
    Alpha oAlpha = new Alpha(MaxCount);
    oAlpha.eventX = eventX;
    Console.WriteLine("Queue to Thread Pool 0");
    ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),new SomeState(0));
       for (int iItem=1;iItem < MaxCount;iItem++){
         Console.WriteLine("Queue to Thread Pool {0}", iItem);
         ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),
                                   new SomeState(iItem));
       }
    Console.WriteLine("Waiting for Thread Pool to drain");
    eventX.WaitOne(Timeout.Infinite,true);
    Console.WriteLine("Thread Pool has been drained (Event fired)");
    Console.WriteLine("Load across threads");
    for(int iIndex=0;iIndex<oAlpha.HashCount.Length;iIndex++)
      Console.WriteLine("{0} {1}", iIndex, oAlpha.HashCount[iIndex]);
    }
    return 0;
  }
}

O JIT

Noções básicas

Assim como acontece com qualquer VM, o CLR precisa de uma maneira de compilar a linguagem intermediária até o código nativo. Quando você compila um programa para ser executado no CLR, o compilador leva sua origem de uma linguagem de alto nível para uma combinação de MSIL (Linguagem Intermediária da Microsoft) e metadados. Eles são mesclados em um arquivo PE, que pode ser executado em qualquer computador compatível com CLR. Quando você executa esse executável, o JIT começa a compilar o IL até o código nativo e a executar esse código no computador real. Isso é feito por método, portanto, o atraso para JITing é apenas o tempo necessário para o código que você deseja executar.

O JIT é muito rápido e gera um código muito bom. Algumas das otimizações executadas (e algumas explicações de cada uma) são discutidas abaixo. Tenha em mente que a maioria dessas otimizações tem limites impostos para garantir que o JIT não gaste muito tempo.

  • Dobragem constante – calcule valores constantes em tempo de compilação.

    Antes After (após)
    x = 5 + 7 x = 12
  • Constante e propagação de cópia – substitua para trás para liberar variáveis anteriormente.

    Antes After (após)
    x = a x = a
    y = x y = a
    z = 3 + y z = 3 + a
  • Inlining do método – substitua args por valores passados no momento da chamada e elimine a chamada. Muitas outras otimizações podem ser executadas para recortar o código inativo. Por motivos de velocidade, o JIT atual tem vários limites sobre o que pode ser embutido. Por exemplo, apenas métodos pequenos são embutidos (tamanho il menor que 32) e a análise de controle de fluxo é bastante primitiva.

    Antes After (após)
    ...

    x=foo(4, true);

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

    ...

    x = 9

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

  • Içamento de código e dominadores – remova o código de loops internos se ele estiver duplicado fora. O exemplo 'before' abaixo é, na verdade, o que é gerado no nível il, pois todos os índices de matriz precisam ser verificados.

    Antes After (após)
    for(i=0; i< a.length;i++){

    if(i < a.length()){

    a[i] = null

    } else {

    raise IndexOutOfBounds;

    }

    }

    for(int i=0; i<a.length; i++){

    a[i] = null;

    }

  • Loop Unrolling — a sobrecarga de incrementar contadores e executar o teste pode ser removida e o código do loop pode ser repetido. Para loops extremamente apertados, isso resulta em uma vitória de desempenho.

    Antes After (após)
    for(i=0; i< 3; i++){

    print("flaming monkeys!");

    }

    print("flaming monkeys!");

    print("flaming monkeys!");

    print("flaming monkeys!");

  • Eliminação de SubExpression Comum – se uma variável dinâmica ainda contiver as informações que estão sendo recompactadas, use-a em vez disso.

    Antes After (após)
    x = 4 + y

    z = 4 + y

    x = 4 + y

    z = x

  • Registro – não é útil dar um exemplo de código aqui, portanto, uma explicação terá que ser suficiente. Essa otimização pode gastar tempo analisando como locais e temporários são usados em uma função e tentar lidar com a atribuição de registro da forma mais eficiente possível. Essa pode ser uma otimização extremamente cara, e o CLR JIT atual considera apenas um máximo de 64 variáveis locais para registro. As variáveis que não são consideradas são colocadas no quadro de pilha. Este é um exemplo clássico das limitações do JITing: embora isso seja bom 99% do tempo, funções altamente incomuns que têm mais de 100 locais serão otimizadas melhor usando a pré-compilação tradicional e demorada.

  • Misc — Outras otimizações simples são executadas, mas a lista acima é uma boa amostra. O JIT também passa para código morto e outras otimizações de peephole.

Quando o código é jited?

Este é o caminho pelo qual seu código passa quando ele é executado:

  1. Seu programa é carregado e uma tabela de funções é inicializada com ponteiros referenciando a IL.
  2. O método Main é JITed em código nativo, que é executado em seguida. As chamadas para funções são compiladas em chamadas de função indireta por meio da tabela.
  3. Quando outro método é chamado, o tempo de execução examina a tabela para ver se ele aponta para o código JITed.
    1. Se ele tiver (talvez tenha sido chamado de outro site de chamada ou tiver sido pré-compilado), o fluxo de controle continuará.
    2. Caso contrário, o método é JITed e a tabela é atualizada.
  4. Como são chamados, cada vez mais métodos são compilados em código nativo e mais entradas no ponto de tabela no pool crescente de instruções x86.
  5. Conforme o programa é executado, o JIT é chamado com cada vez menos frequência até que tudo seja compilado.
  6. Um método não é JITed até ser chamado e nunca mais é JITed durante a execução do programa. Você só paga pelo que usa.

Mito: Programas JITed são executados mais lentamente do que programas pré-compilados

Esse raramente é o caso. A sobrecarga associada ao JITing de alguns métodos é menor em comparação com o tempo gasto lendo em algumas páginas do disco e os métodos são JITed apenas conforme necessário. O tempo gasto no JIT é tão pequeno que quase nunca é perceptível, e uma vez que um método tenha sido JITed, você nunca incorre o custo para esse método novamente. Falarei mais sobre isso na seção Código de Pré-compilação.

Conforme mencionado acima, o JIT versão1 (v1) faz a maioria das otimizações que um compilador faz e só ficará mais rápido na próxima versão (vNext), à medida que otimizações mais avançadas forem adicionadas. Mais importante, o JIT pode executar algumas otimizações que um compilador regular não pode, como otimizações específicas da CPU e ajuste de cache.

Otimizações de JIT-Only

Como o JIT é ativado em tempo de execução, há muitas informações sobre as quais um compilador não está ciente. Isso permite que ele execute várias otimizações que só estão disponíveis em tempo de execução:

  • Otimizações específicas do processador— Em tempo de execução, o JIT sabe se pode ou não usar as instruções SSE ou 3DNow. Seu executável será compilado especialmente para P4, Athlon ou quaisquer famílias de processadores futuras. Você implantará uma vez e o mesmo código melhorará junto com o JIT e o computador do usuário.
  • Otimizando os níveis de indireção, uma vez que a função e o local do objeto estão disponíveis em tempo de execução.
  • O JIT pode executar otimizações entre assemblies, fornecendo muitos dos benefícios que você obtém ao compilar um programa com bibliotecas estáticas, mas mantendo a flexibilidade e o pequeno volume de uso dos dinâmicos.
  • Funções agressivamente embutidas que são chamadas com mais frequência, pois ela está ciente do fluxo de controle durante o tempo de execução. As otimizações podem fornecer um aumento substancial de velocidade e há muito espaço para melhorias adicionais no vNext.

Essas melhorias no tempo de execução vêm às custas de um pequeno custo de inicialização única e podem mais do que compensar o tempo gasto no JIT.

Pré-compilação de código (usando ngen.exe)

Para um fornecedor de aplicativos, a capacidade de pré-compilar código durante a instalação é uma opção atraente. A Microsoft fornece essa opção no formulário ngen.exe, que permitirá que você execute o compilador JIT normal em todo o programa uma vez e salve o resultado. Como as otimizações somente de tempo de execução não podem ser executadas durante a pré-compilação, o código gerado geralmente não é tão bom quanto o gerado por um JIT normal. No entanto, sem a necessidade de métodos JIT em tempo real, o custo de inicialização é muito menor e alguns programas serão lançados visivelmente mais rapidamente. No futuro, ngen.exe pode fazer mais do que simplesmente executar o mesmo JIT de tempo de execução: otimizações mais agressivas com limites mais altos do que o tempo de execução, exposição de otimização de ordem de carga aos desenvolvedores (otimizando a maneira como o código é empacotado em páginas de VM) e otimizações mais complexas e demoradas que podem aproveitar o tempo durante a pré-compilação.

Reduzir o tempo de inicialização ajuda em dois casos e, para todo o resto, ele não compete com as otimizações somente de tempo de execução que o JITing regular pode fazer. A primeira situação é quando você chama um enorme número de métodos no início do programa. Você terá que JIT com muitos métodos antecipadamente, resultando em um tempo de carga inaceitável. Este não será o caso para a maioria das pessoas, mas pré-JITing pode fazer sentido se isso afetar você. A pré-compilação também faz sentido no caso de bibliotecas compartilhadas grandes, pois você paga o custo de carregá-las com muito mais frequência. A Microsoft pré-compila as Estruturas para o CLR, pois a maioria dos aplicativos as usará.

É fácil usar ngen.exe para ver se a pré-compilação é a resposta para você, portanto, recomendo experimentá-la. No entanto, na maioria das vezes, é melhor usar o JIT normal e aproveitar as otimizações em tempo de execução. Eles têm um enorme pagamento, e mais do que compensarão o custo de inicialização única na maioria das situações.

Ajuste de velocidade

Para o programador, há apenas duas coisas que valem a pena notar. Primeiro, que o JIT é muito inteligente. Não tente achar melhor o compilador. Codificar da maneira que você normalmente faria. Por exemplo, suponha que você tenha o seguinte código:

...

for(int i = 0; i < myArray.length; i++){

...

}

...

...

int l = myArray.length;

for(int i = 0; i < l; i++){

...

}

...

Alguns programadores acreditam que podem obter um aumento de velocidade movendo o cálculo de comprimento para fora e salvando-o em um temporário, como no exemplo à direita.

A verdade é que otimizações como essa não são úteis há quase 10 anos: os compiladores modernos são mais do que capazes de executar essa otimização para você. Na verdade, às vezes coisas assim podem realmente prejudicar o desempenho. No exemplo acima, um compilador provavelmente marcar ver que o comprimento de myArray é constante e inserir uma constante na comparação do loop for. Mas o código à direita pode enganar o compilador para pensar que esse valor deve ser armazenado em um registro, já l que está ativo durante todo o loop. A linha de fundo é: escreva o código que é o mais legível e isso faz mais sentido. Não vai ajudar a tentar superar o compilador e, às vezes, pode doer.

A segunda coisa sobre a qual falar são as chamadas final. No momento, os compiladores C# e Microsoft® Visual Basic® não fornecem a capacidade de especificar que uma chamada final deve ser usada. Se você realmente precisar desse recurso, uma opção é abrir o arquivo PE em um desmontador e usar a instrução .tail do MSIL. Essa não é uma solução elegante, mas as chamadas final não são tão úteis no C# e no Visual Basic como em linguagens como Scheme ou ML. Pessoas compiladores de escrita para linguagens que realmente aproveitam as chamadas final devem usar esta instrução. A realidade para a maioria das pessoas é que mesmo ajustar manualmente a IL para usar chamadas final não oferece um enorme benefício de velocidade. Às vezes, o tempo de execução realmente os transformará novamente em chamadas regulares, por motivos de segurança! Talvez em versões futuras mais esforço seja colocado em para apoiar as chamadas final, mas no momento o ganho de desempenho é insuficiente para garantir isso, e muito poucos programadores vão querer tirar proveito disso.

AppDomains

Noções básicas

A comunicação entre processos está se tornando cada vez mais comum. Por motivos de estabilidade e segurança, o sistema operacional mantém aplicativos em espaços de endereço separados. Um exemplo simples é a maneira como todos os aplicativos de 16 bits são executados no NT: se executados em um processo separado, um aplicativo não pode interferir na execução de outro. O problema aqui é o custo da opção de contexto e a abertura de uma conexão entre processos. Esta operação é muito cara, e prejudica muito o desempenho. Em aplicativos de servidor, que geralmente hospedam vários aplicativos Web, esse é um grande dreno no desempenho e na escalabilidade.

O CLR apresenta o conceito de um AppDomain, que é semelhante a um processo em que ele é um espaço independente para um aplicativo. No entanto, Os AppDomains não estão restritos a um por processo. É possível executar dois AppDomains completamente não relacionados no mesmo processo, graças à segurança de tipo fornecida pelo código gerenciado. O aumento de desempenho aqui é enorme para situações em que você normalmente gasta muito do seu tempo de execução em sobrecarga de comunicação entre processos: o IPC entre assemblies é cinco vezes mais rápido do que entre os processos no NT. Ao reduzir esse custo drasticamente, você obtém um aumento de velocidade e uma nova opção durante o design do programa: agora faz sentido usar processos separados em que antes poderia ter sido muito caro. A capacidade de executar vários programas no mesmo processo com a mesma segurança de antes tem enormes implicações para escalabilidade e segurança.

O suporte para AppDomains não está presente no sistema operacional. Os AppDomains são tratados por um host CLR, como os presentes em ASP.NET, um executável de shell ou Explorer da Microsoft Internet. Você também pode escrever o seu próprio. Cada host especifica um domínio padrão, que é carregado quando o aplicativo é iniciado pela primeira vez e só é fechado quando o processo é encerrado. Ao carregar outros assemblies no processo, você pode especificar que eles sejam carregados em um AppDomain específico e definir políticas de segurança diferentes para cada um deles. Isso é descrito com mais detalhes na documentação do SDK do Microsoft .NET Framework.

Ajuste de velocidade

Para usar o AppDomains com eficiência, você precisa pensar sobre que tipo de aplicativo você está escrevendo e que tipo de trabalho ele precisa fazer. Como uma boa regra geral para passar, Os AppDomains são mais eficazes quando seu aplicativo se ajusta a algumas das seguintes características:

  • Ele gera uma nova cópia de si mesmo com frequência.
  • Ele funciona com outros aplicativos para processar informações (consultas de banco de dados dentro de um servidor Web, por exemplo).
  • Ele passa muito tempo no IPC com programas que funcionam exclusivamente com seu aplicativo.
  • Ele abre e fecha outros programas.

Um exemplo de uma situação em que Os AppDomains são úteis pode ser visto em um aplicativo ASP.NET complexo. Suponha que você queira impor o isolamento entre vRoots diferentes: no espaço nativo, você precisa colocar cada vRoot em um processo separado. Isso é bastante caro, e a alternância de contexto entre eles é muita sobrecarga. No mundo gerenciado, cada vRoot pode ser um AppDomain separado. Isso preserva o isolamento necessário enquanto reduz drasticamente a sobrecarga.

AppDomains são algo que você deve usar somente se seu aplicativo for complexo o suficiente para exigir o trabalho em estreita colaboração com outros processos ou outras instâncias de si mesmo. Embora a comunicação iter-AppDomain seja muito mais rápida do que a comunicação entre processos, o custo de iniciar e fechar um AppDomain pode realmente ser mais caro. Os AppDomains podem acabar prejudicando o desempenho quando usados pelos motivos errados, portanto, certifique-se de usá-los nas situações certas. Observe que somente o código gerenciado pode ser carregado em um AppDomain, pois o código não gerenciado não pode ter garantia de segurança.

Assemblies compartilhados entre vários AppDomains devem ser JITed para cada domínio, a fim de preservar o isolamento entre domínios. Isso resulta em muita criação de código duplicado e memória desperdiçada. Considere o caso de um aplicativo que responde a solicitações com algum tipo de serviço XML. Se determinadas solicitações precisarem ser mantidas isoladas umas das outras, você precisará encaminhá-las para diferentes AppDomains. O problema aqui é que cada AppDomain agora exigirá as mesmas bibliotecas XML e o mesmo assembly será carregado várias vezes.

Uma maneira de contornar isso é declarar um assembly como Neutro em Domínio, o que significa que nenhuma referência direta é permitida e o isolamento é imposto por meio de indireção. Isso economiza tempo, já que o assembly é JITed apenas uma vez. Ele também salva memória, pois nada é duplicado. Infelizmente, há um impacto no desempenho devido à indireção necessária. Declarar um assembly como neutro em termos de domínio resulta em uma vitória de desempenho quando a memória é uma preocupação ou quando muito tempo é desperdiçado código JITing. Cenários como esse são comuns no caso de um assembly grande que é compartilhado por vários domínios.

Segurança

Noções básicas

A segurança de acesso ao código é um recurso poderoso e extremamente útil. Ele oferece aos usuários uma execução segura de código semi-confiável, protege contra software mal-intencionado e vários tipos de ataques e permite acesso controlado baseado em identidade aos recursos. No código nativo, a segurança é extremamente difícil de fornecer, pois há pouca segurança de tipo e o programador manipula a memória. No CLR, o tempo de execução sabe o suficiente sobre a execução de código para adicionar suporte de segurança forte, um recurso que é novo para a maioria dos programadores.

A segurança afeta a velocidade e o tamanho do conjunto de trabalho de um aplicativo. E, assim como acontece com a maioria das áreas de programação, como o desenvolvedor usa a segurança pode determinar consideravelmente seu impacto no desempenho. O sistema de segurança foi projetado com o desempenho em mente e deve, na maioria dos casos, ter um bom desempenho com pouco ou nenhum pensamento dado a ele pelo desenvolvedor do aplicativo. No entanto, há várias coisas que você pode fazer para espremer o último bit de desempenho do sistema de segurança.

Ajustando a velocidade

A execução de uma marcar de segurança normalmente requer um passo a passo da pilha para garantir que o código que chama o método atual tenha as permissões corretas. O tempo de execução tem várias otimizações que ajudam a evitar percorrer toda a pilha, mas há várias coisas que o programador pode fazer para ajudar. Isso nos leva à noção de segurança imperativa versus declarativa: a segurança declarativa adorna um tipo ou seus membros com várias permissões, enquanto a segurança imperativa cria um objeto de segurança e executa operações nele.

  • A segurança declarativa é a maneira mais rápida de ir para Assert, Deny e PermitOnly. Essas operações normalmente exigem uma caminhada de pilha para localizar o quadro de chamada correto, mas isso pode ser evitado se você declarar explicitamente esses modificadores. As demandas são mais rápidas se feitas imperativamente.
  • Ao fazer a interoperabilidade com código não gerenciado, você pode remover as verificações de segurança em tempo de execução usando o atributo SuppressUnmanagedCodeSecurity. Isso move a marcar para o tempo de vinculação, o que é muito mais rápido. Por precaução, certifique-se de que o código não exponha nenhuma falha de segurança em outro código, o que pode explorar os marcar removidos em código não seguro.
  • As verificações de identidade são mais caras do que as verificações de código. Você pode usar LinkDemand para fazer essas verificações no momento do link.

Há duas maneiras de otimizar a segurança:

  • Execute verificações no momento do link em vez do tempo de execução.
  • Torne as verificações de segurança declarativas, em vez de imperativas.

A primeira coisa em que você deve se concentrar é mover o máximo possível dessas verificações para o tempo de vinculação. Tenha em mente que isso pode afetar a segurança do aplicativo, portanto, certifique-se de que você não mova as verificações para o vinculador que dependem do estado de tempo de execução. Depois de mover o máximo possível para o tempo de link, você deve otimizar as verificações de tempo de execução usando a segurança declarativa ou imperativa: escolha qual é ideal para o tipo específico de marcar você usa.

Comunicação remota

Noções básicas

A tecnologia de comunicação remota no .NET estende o sistema de tipos avançados e a funcionalidade do CLR pela rede. Usando XML, SOAP e HTTP, você pode chamar procedimentos e passar objetos remotamente, assim como se estivessem hospedados no mesmo computador. Você pode pensar nisso como a versão do .NET do DCOM ou CORBA, pois ela fornece um superconjunto de suas funcionalidades.

Isso é particularmente útil em um ambiente de servidor, quando você tem vários servidores hospedando serviços diferentes, todos conversando entre si para vincular esses serviços perfeitamente. A escalabilidade também é aprimorada, pois os processos podem ser fisicamente divididos em vários computadores sem perder a funcionalidade.

Ajustando a velocidade

Como a comunicação remota geralmente incorre em uma penalidade em termos de latência de rede, as mesmas regras se aplicam ao CLR que sempre tem: tente minimizar a quantidade de tráfego que você envia e evite que o restante do programa aguarde o retorno de uma chamada remota. Aqui estão algumas boas regras para viver ao usar a comunicação remota para maximizar o desempenho:

  • Faça Chamadas Volumosas em vez de Chamadas Tagarelas. Veja se você pode reduzir o número de chamadas que precisa fazer remotamente. Por exemplo, suponha que você defina algumas propriedades para um objeto remoto usando métodos get() e set( ). Isso pouparia tempo para simplesmente recriar o objeto remotamente, com essas propriedades definidas na criação. Como isso pode ser feito usando uma única chamada remota, você economizará tempo perdido no tráfego de rede. Às vezes, pode fazer sentido mover o objeto para o computador local, definir as propriedades lá e copiá-lo de volta. Dependendo da largura de banda e da latência, às vezes uma solução fará mais sentido do que a outra.
  • Balancear a carga da CPU com a carga de rede — às vezes, faz sentido enviar algo a ser feito pela rede e, outras vezes, é melhor fazer o trabalho por conta própria. Se você perder muito tempo atravessando a rede, seu desempenho sofrerá. Se você usar muito de sua CPU, não poderá responder a outras solicitações. Encontrar um bom equilíbrio entre esses dois é essencial para fazer com que seu aplicativo seja dimensionado.
  • Usar Chamadas Assíncronas – ao fazer uma chamada pela rede, verifique se ela é assíncrona, a menos que você realmente precise de outra forma. Caso contrário, seu aplicativo será interrompido até receber uma resposta e isso pode ser inaceitável em uma interface do usuário ou em um servidor de alto volume. Um bom exemplo a ser olhado está disponível no SDK do Framework fornecido com o .NET, em "Samples\technologies\remoting\advanced\asyncdelegate".
  • Usar objetos de forma ideal – você pode especificar que um novo objeto seja criado para cada solicitação (SingleCall) ou que o mesmo objeto seja usado para todas as solicitações (Singleton). Ter um único objeto para todas as solicitações certamente é menos intensivo em recursos, mas você precisará ter cuidado com a sincronização e a configuração do objeto de solicitação a solicitação.
  • Fazer uso de canais conectáveis e formatadores — um recurso poderoso de comunicação remota é a capacidade de conectar qualquer canal ou formatador ao seu aplicativo. Por exemplo, a menos que você precise passar por um firewall, não há motivo para usar o canal HTTP. Conectar um canal TCP lhe dará um desempenho muito melhor. Escolha o canal ou o formatador que é melhor para você.

ValueTypes

Noções básicas

A flexibilidade oferecida pelos objetos tem um pequeno preço de desempenho. Objetos gerenciados por heap levam mais tempo para alocar, acessar e atualizar do que os gerenciados por pilha. É por isso que, por exemplo, um struct em C++ muito mais eficiente do que um objeto . É claro que os objetos podem fazer coisas que os structs não podem e são muito mais versáteis.

Mas às vezes você não precisa de toda essa flexibilidade. Às vezes, você quer algo tão simples quanto um struct e não quer pagar o custo de desempenho. O CLR fornece a capacidade de especificar o que é chamado de ValueType e, em tempo de compilação, isso é tratado como um struct. ValueTypes são gerenciados pela pilha e fornecem toda a velocidade de um struct. Como esperado, eles também vêm com a flexibilidade limitada de structs (não há herança, por exemplo). Mas para as instâncias em que tudo o que você precisa é de um struct, ValueTypes fornecem um aumento de velocidade incrível. Informações mais detalhadas sobre ValueTypes e o restante do sistema de tipos CLR estão disponíveis no Biblioteca MSDN.

Ajustando a velocidade

ValueTypes são úteis somente nos casos em que você os usa como structs. Se você precisar tratar um ValueType como um objeto, o tempo de execução manipulará a conversão boxing e a unboxing do objeto para você. No entanto, isso é ainda mais caro do que criá-lo como um objeto em primeiro lugar!

Aqui está um exemplo de um teste simples que compara o tempo necessário para criar um grande número de objetos e ValueTypes:

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
class Class1{
  static void Main(string[] args){
    Console.WriteLine("starting struct loop....");
    int t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      foo test1 = new foo(3.14);
      foo test2 = new foo(3.15);
       if (test1.y == test2.y) break; // prevent code from being 
       eliminated JIT
    }
    int t2 = Environment.TickCount;
    Console.WriteLine("struct loop: (" + (t2-t1) + "). starting object 
       loop....");
    t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      bar test1 = new bar(3.14);
      bar test2 = new bar(3.15);
      if (test1.y == test2.y) break; // prevent code from being 
      eliminated JIT
    }
    t2 = Environment.TickCount;
    Console.WriteLine("object loop: (" + (t2-t1) + ")");
    }

Tente você mesmo. O intervalo de tempo está na ordem de vários segundos. Agora, vamos modificar o programa para que o tempo de execução tenha que fazer a caixa e desmarcar nosso struct. Observe que os benefícios de velocidade de usar um ValueType desapareceram completamente! A moral aqui é que ValueTypes são usados apenas em situações extremamente raras, quando você não os usa como objetos. É importante olhar para essas situações, já que a vitória de desempenho geralmente é extremamente grande quando você as usa corretamente.

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
  class Class1{
    static void Main(string[] args){
      Hashtable boxed_table = new Hashtable(2);
      Hashtable object_table = new Hashtable(2);
      System.Console.WriteLine("starting struct loop...");
      for(int i = 0; i < 10000000; i++){
        boxed_table.Add(1, new foo(3.14)); 
        boxed_table.Add(2, new foo(3.15));
        boxed_table.Remove(1);
      }
      System.Console.WriteLine("struct loop complete. 
                                starting object loop...");
      for(int i = 0; i < 10000000; i++){
        object_table.Add(1, new bar(3.14)); 
        object_table.Add(2, new bar(3.15));
        object_table.Remove(1);
      }
      System.Console.WriteLine("All done");
    }
  }
}

A Microsoft usa ValueTypes em grande parte: todos os primitivos nas Estruturas são ValueTypes. Minha recomendação é que você use ValueTypes sempre que se sentir ansioso por um struct. Desde que você não box/unbox, eles podem fornecer um enorme aumento de velocidade.

Uma coisa extremamente importante a observar é que ValueTypes não exigem marshalling em cenários de interoperabilidade. Como o marshalling é um dos maiores sucessos de desempenho ao interoperar com código nativo, usar ValueTypes como argumentos para funções nativas talvez seja o maior ajuste de desempenho que você pode fazer.

Recursos adicionais

Os tópicos relacionados sobre o desempenho no .NET Framework incluem:

Assista a artigos futuros atualmente em desenvolvimento, incluindo uma visão geral das filosofias de design, arquitetura e codificação, um passo a passo das ferramentas de análise de desempenho no mundo gerenciado e uma comparação de desempenho do .NET com outros aplicativos empresariais disponíveis hoje.

Apêndice: hospedando o tempo de execução do servidor

#include "mscoree.h"
#include "stdio.h"
#import "mscorlib.tlb" named_guids no_namespace raw_interfaces_only \
no_implementation exclude("IID_IObjectHandle", "IObjectHandle")

long main(){
  long retval = 0;
  LPWSTR pszFlavor = L"svr";

  // Bind to the Run time.
  ICorRuntimeHost *pHost = NULL;
  HRESULT hr = CorBindToRuntimeEx(NULL,
               pszFlavor, 
               NULL,
               CLSID_CorRuntimeHost, 
               IID_ICorRuntimeHost, 
               (void **)&pHost);

  if (SUCCEEDED(hr)){
    printf("Got ICorRuntimeHost\n");
      
    // Start the Run time (this also creates a default AppDomain)
    hr = pHost->Start();
    if(SUCCEEDED(hr)){
      printf("Started\n");
         
      // Get the Default AppDomain created when we called Start
      IUnknown *pUnk = NULL;
      hr = pHost->GetDefaultDomain(&pUnk);

      if(SUCCEEDED(hr)){
        printf("Got IUnknown\n");
            
        // Ask for the _AppDomain Interface
        _AppDomain *pDomain = NULL;
        hr = pUnk->QueryInterface(IID__AppDomain, (void**)&pDomain);
            
        if(SUCCEEDED(hr)){
          printf("Got _AppDomain\n");
               
          // Execute Assembly's entry point on this thread
          BSTR pszAssemblyName = SysAllocString(L"Managed.exe");
          hr = pDomain->ExecuteAssembly_2(pszAssemblyName, &retval);
          SysFreeString(pszAssemblyName);
               
          if (SUCCEEDED(hr)){
            printf("Execution completed\n");

            //Execution completed Successfully
            pDomain->Release();
            pUnk->Release();
            pHost->Stop();
            
            return retval;
          }
        }
        pDomain->Release();
        pUnk->Release();
      }
    }
    pHost->Release();
  }
  printf("Failure, HRESULT: %x\n", hr);
   
  // If we got here, there was an error, return the HRESULT
  return hr;
}

Se você tiver dúvidas ou comentários sobre este artigo, entre em contato com Claudio Caldato, gerente de programas para .NET Framework problemas de desempenho.