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:
Os Tipos de Referência são sempre instanciados na memória Heap;
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: