Nesse terceiro post sobre conceitos fundamentais para performance, iremos tratar de Boxing e Unboxing.
Há outros dois posts, dessa série, um falando sobre a stack e outro sobre a heap, que mostram conceitos fundamentais para que você possa entender do que estaremos tratando aqui. Caso ainda não lido esses posts, ou ainda não esteja seguro quanto a esses conceitos, recomendo tomar um tempo para a leitura e, eventualmente, resolver suas dúvidas nos comentários.
O que é Boxing e Unboxing
Boxing ocorre quando um valor (literal ou struct) está na stack e, por alguma razão, o movemos para a heap. Unboxing é o processo inverso.
Considere o seguinte exemplo:
class Program { public static void Main() { int value_stack = 4; object value_heap = value_stack; // boxing int value_unboxed = (int) value_heap; } }
No código acima, value_stack é uma variável de um tipo primitivo (que é alocado na stack). Quando atribuímos o valor dessa variável para outra, do tipo object, forçamos que o valor seja levado para a heap (acontecendo o boxing) – afinal, todas os valores do tipo object e seus derivados (todas as classes que você escreve) só podem ser armazenados na heap. Por fim, quando criamos uma variável de um tipo primitivo e fazemos o cast de um object para este tipo, trazemos o valor de volta para stack (acontecendo o unboxing)
Por que boxing e unboxing é importante para performance?
Há alguns aspectos fundamentais aqui:
- valores na heap ocupam mais memória – cada variável que que aponta para valores na heap tem um ponteiro (de 32 bits ou 64 bits) com a posição de memória desses valores (espaço adicional ao espaço ocupado pelo objeto em si). Além disso, os objetos na heap estão sempre envoltos em uma estrutura de dados.
- acesso a heap é mais lento, por razões óbvias
- a desalocação de objetos na heap é mais custosa – em .NET, a desalocação de objetos da heap ocorre de forma não determinística através da execução do Garbage Collector que, por sua vez, suspende a execução da aplicação sempre que é executado.
Fazer boxing de forma descuidada pode influenciar consideravelmente o tempo de execução da aplicação e o volume de memória RAM consumido.
O problema do boxing não evidente
Considere o código que segue:
using System; using System.Diagnostics; namespace ConsoleApp1 { class Program { static void Main(string[] args) { var f1 = new Foo {Value = 5}; var f2 = new Foo {Value = 5}; var f3 = new Foo {Value = 6}; var sw = new Stopwatch(); sw.Start(); var gc0 = GC.CollectionCount(0); var gc1 = GC.CollectionCount(1); for (int i = 0; i < 10_000_000; i++) { if (!f1.Equals(f2)) { Console.WriteLine("Failed"); } if (f1.Equals(f3)) { Console.WriteLine("Failed"); } } Console.WriteLine($"Ellapsed time: {sw.ElapsedMilliseconds} ms"); Console.WriteLine($"GC0 count : {GC.CollectionCount(0) - gc0 }"); Console.ReadLine(); } } struct Foo { public int Value { get; set; } } }
A execução desse código, em meu computador, gerou a seguinte saída:
Como você pode ver, foram 228 coletas (intervenções do garbage collector, com suspensão, em menos de um segundo de execução.) em menos de um segundo.
Seguramente, a implementação padrão de Equals é bem pouco eficiente (gerando uma quantidade de alocações alta, principalmente por fazer uso intensivo de reflection). Mas, uma implementação personalizada não resolve inteiramente o problema.
struct Foo { public int Value { get; set; } public override bool Equals(object obj) { if (obj == null) { return false; } if (obj is Foo f) { return f.Value == this.Value; } return false; } }
Nessa implementação, fazemos uma comparação econômica entre as instâncias.
O volume de acionamentos do Garbage Collector caiu consideravelmente. Entretanto, ainda ocorre.
O problema, é que o parâmetro de Equals é do tipo object. Assim, a cada chamada, temos um boxing levando a struct para a heap.
Vamos revisitar esse problema, implementando a interface de comparação apropriada que nos permite impedir o boxing.
struct Foo : IEquatable { public int Value { get; set; } public bool Equals(Foo other) { return Value == other.Value; } }
Como você pode ver, dessa vez, o parâmetro para Equals é o tipo da struct. Ou seja, não ocorrerá boxing.
Executando o código, temos o seguinte resultado:
Sem boxing, sem garbage collections. Sem garbage collections, sem interrupções. Sem interrupções, o programa roda muito mais rápido.
Há, em toda API do .NET centenas de métodos esperando argumentos object. Sempre que passamos uma struct ou um tipo primitivo como parâmetro, o boxing acontece.
Há ainda outros exemplos. Mas que não vamos tratar aqui hoje.
Fechando … por enquanto
Neste post, conseguimos demonstrar o que é boxing (e unboxing) destacando o impacto para a performance.
Nos próximos posts dessa série, continuaremos tratando das pequenas armadilhas técnicas que, sem que percebamos, consomem importantes recursos computacionais.
Mais uma vez, recomendamos que você veja o post que explica a stack e outro sobre a heap. Outra boa pedida é ver como Rust gerencia a heap (afinal, há vida fora de .NET). Por fim, se estiver trabalhando com algum problema que exija processamento paralelo massivo, sugerimos que considere CUDA.
Parabéns pelo post Elemar, de fato este tipo de conteúdo agrega muito mais que saber organizar pastas.
Parabéns Elemar!
Achei excelente os artigos sobre esses fundamentos. Espero ver mais sobre esse assunto.
Desenvolvo C# a anos, sempre me preocupei com performe do que desenvolvo, mas nunca havia feito uma analise a esse nível.
Apoio essa sua idéia de que temos que conhecer bem o fundamento.
Abraço e sucesso!
Fantástico como sempre, valeu Elemar!
Fantástico!