C# - Melhorando o desempenho : classes ou structs ?
  Hoje vamos recordar alguns conceitos importantes sobre classes e structs e ver em como isso pode nos ajudar a melhorar o desempenho do nosso código C#.


Na plataforma .NET como temos o gerenciamento de memória e o coletor de lixo não precisamos nos preocupar com a alocação da memória. Certo ?
 



 

Não é bem assim, embora a plataforma ofereça recurso que facilita a vida do desenvolvedor temos que sempre estar atentos e utilizar práticas para otimizar a alocação de memória e melhorar o desempenho do código.

 

No dia a dia da vida de programador podemos adotar hábitos comuns dos desenvolvedores de C#, que são bastante ineficientes em termos de desempenho e uso de memória. Esses hábitos não são intencionais e acontecem apenas porque ou aprendemos desta forma ou porque estamos seguindo o que a documentação recomenda.

 

Como o assunto é vasto neste artigo vou focar apenas no uso de classes e structs e em como isso pode afetar o desempenho e evitar o desperdício de memória.

 

Introdução : Stack e Heap


Uma das principais razões pelas quais alguns códigos otimizados são executados mais rapidamente do que códigos não otimizados é uma melhor alocação de memória na Heap e na Stack.

Então, o que é o Stack e a Heap?

 

Em termos simples, a Stack é uma área pequena da memória onde cada nova chamada de método é empilhada no seu topo durante e removida quando o método termina. Você pode imaginar um método como uma caixa, que é colocada no topo da Stack. Esta caixa pode conter variáveis, ponteiros e instruções.

 

O Heap é uma área de memória maior que pode ser vista como um contêiner um pouco mais confuso, onde tudo mais complexo, ou de vida mais longa é jogado temporariamente. Objetos mais complexos custarão mais para serem escritos e lidos. Quando o objeto não é mais referenciado, ele eventualmente será removido da memória Heap pelo coletor de lixo.

 

É aqui é que fica interessante: enquanto o Stack está se limpando perfeitamente, o Heap precisa ser limpo separadamente pelo Garbage Collector, o que leva tempo e tem um custo em termos de desempenho.
 

Durante a coleta de lixo, seu aplicativo para completamente de executar!

Se você não prestar atenção, a coleta de lixo pode facilmente ocupar mais de 2% do tempo de execução do seu aplicativo, apenas coletando objetos não referenciados, o que é muito.

Esta é exatamente a razão pela qual você deve prestar atenção em onde suas variáveis são armazenadas durante a execução. Duas regras de ouro, são as seguintes:

  1. Os Tipos de Referência são sempre instanciados na memória Heap;

  2. Tipos de valor e ponteiros são instanciados no escopo do pai declarante. Isso significa que quando você instancia um tipo de valor dentro de um tipo de referência, ele também irá para a memória Heap;

Com isso em mente vamos agora como o uso de classes e structs pode afetar o seu código.
 

Considere com cuidado o uso da Stack e da Heap

 

Um equívoco comum é pensar que qualquer tipo de valor é sempre colocado na memória Stack. Isso só é verdade, quando a variável também está dentro do escopo da Stack.

 

Vamos dar uma olhada no exemplo a seguir:



A criação de uma nova instância de um objeto requer que ele seja armazenado na memória Heap, que, consequentemente, deve armazenar quaisquer propriedades contidas também na Heap, o que é muito mais lento do que apenas uma simples alocação de na Stack.

Para algumas instâncias com uma carga de trabalho baixa, isso normalmente não é um problema. No entanto, quando você dimensiona e inicializa centenas ou milhares de classes, onde um tipo de valor simples pode ser suficiente, você definitivamente deve usá-lo e a seguir vamos entender como fazer isso.

 

Usar Classes ou Structs ?

Também fortemente correlacionada à alocação da memória Heap e da Stack está a decisão em usar uma classe ou uma struct para seus objetos de transferência de dados.

 

A diferença é que structs são tipos de valor, o que os torna capazes de serem alocados na pilha. No entanto, lembre-se de que os tipos de valor também são tratados como imutáveis. Isso significa que toda vez que você fizer uma alteração nele, ou passá-lo para outro escopo, ele será copiado na memória.

No tópico anterior vimos que você deve alocar variáveis na Stack, quando possível, para ganhar algum desempenho. No entanto, há um porém. Trabalhar apenas no Stack também pode deixá-lo mais lento quando você não presta atenção na clonagem de objetos na memória.

 

Vamos comparar estruturas e classes com um benchmark bem rápido. Estou usando os recursos da biblioteca BenchmarkDotNet e o código abaixo:

 

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
var resultado =
   BenchmarkRunner.Run< TesteBenchMark>();
Console.ReadKey();
public class TesteBenchMark
{
    [Benchmark]
    public void InicializaStruct()
    {
        _ = new TesteStruct(1);
    }
    [Benchmark]
    public void InicializaClass()
    {
        _ = new TesteClass(1);
    }
    [Benchmark]
    public void InicializaRecordStruct()
    {
        _ = new TesteRecordStruct(1);
    }
}
class TesteClass
{
    public int Id { get; set; }
    public TesteClass(int num)
    {
        Id = num;
    }
}
struct TesteStruct
{
    public int Id { get; set; }
    public TesteStruct(int num)
    {
        Id = num;
    }
}
public record TesteRecordStruct(int num);


Ao final da execução teremos o resultado abaixo:

 

 

Pelo resultado obtido vemos que inicializar uma struct é muito mais rápido do que inicializar uma classe e além disso não aloca memória na Heap.

 

Vamos fazer outro teste desta vez, vamos passar objetos structs e objetos classes para outros métodos e ver o que acontece em termos de desempenho.

 

Para isso vou usar o seguinte código e a biblioteca BenchMarckDotnet:

 

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
var resultado =
   BenchmarkRunner.Run< Teste2BenchMark>();
Console.ReadKey();
public class Teste2BenchMark
{
    [Params(10, 100, 1000)]
    public int ContaPassagem { get; set; }
    [Benchmark]
    public void PassaClasses()
    {
        var x = new TesteClass(1);
        _ = PassaClassesInternal(x, 0);
    }
    [Benchmark]
    public void PassaStructs()
    {
        var x = new TesteStruct(1);
        _ = PassaStructsInternal(x, 0);
    }
    private int PassaClassesInternal(TesteClass testeClass, int contador)
    {
        if (contador >= ContaPassagem) return contador;
        return PassaClassesInternal(testeClass, contador + 1);
    }
    private int PassaStructsInternal(TesteStruct testeStruct, int contador)
    {
        if (contador >= ContaPassagem) return contador;
        return PassaStructsInternal(testeStruct, contador + 1);
    }
}

 

O resultado obtido é o seguinte:

 

 

Vemos que agora as classes apresentam um melhor desempenho que se acentua quanto mais alocações fazemos.

 

Como foi mencionado quando você passa um struct para outra função/metódo, ela está sendo clonada na Stack e isso leva um pouco mais de tempo do que apenas clonar o ponteiro para um objeto de classe na Heap.

Então, o que podemos aprender com esses benchmarks ?

 

Bem, existem alguns casos em que structs têm melhor desempenho e outros em que classes são melhores. Instanciar classes, alocará memória no Heap, mas passá-las para outras funções é mais eficiente do que cloná-las sempre.  


A memória alocada será um problema, quando estiver sendo preenchida até o limite e o coletor de lixo entrar em ação. Lembre-se que durante a coleta de lixo, sua aplicação para de funcionar completamente. É por isso que você deve sempre ficar de olho na alocação da sua memória Heap.

E estamos conversados  ... 

"E a vós outros, que estáveis mortos pelas vossas transgressões e pela incircuncisão da vossa carne, vos deu vida juntamente com ele, perdoando todos os nossos delitos;"
Colossenses 2:13

Referências:


José Carlos Macoratti