Introdução
Pooling é uma estratégia utilizada na programação para reduzir os custos de alocação de certos objetos, que podem ser muito caros para criar e/ou finalizar. Dentro da BCL do .NET existem poucos tipos de pool, sendo os mais famosos o ThreadPool e ArrayPool. Quando usado corretamente, essa estratégia pode ajudar na performance do software; porém, quando usado incorretamente, pode degradar a performance ou deixar o código mais complexo sem alterar o funcionamento do software.
Além dos pools inclusos na BCL do .NET, é possível criar sua própria estratégia de pooling. Caso queira seguir um padrão e não sabe por onde começar, o pacote Microsoft.Extensions.ObjectPool é uma ótima alternativa.
Eu sugiro que você implemente pelos um pooling na vida, para ter uma noção. Isso é algo que tem grandes chances de você usar na sua carreira.
Sugiro também, que esse primeiro pooling seja um ArrayPool, depois que fizer e garantir que ele funciona, dê uma olhada na implementação do .NET, ficou muito diferente? Aprendeu algo novo? Implementação.
Esse é mais um artigo que escrevo motivado pela quantidade de baboseiras que vejo na internet, principalmente no LinkedIn, sobre o quanto todas as pessoas que desenvolvem com .NET deveriam usar o ArrayPool em vez de new T[]. Espero que ao final desse artigo você perceba que essa ferramenta não pode ser utilizada em qualquer lugar e das problemáticas existentes em simplesmente sair usando o ArrayPool sem pensar.
Na real, sempre fique com um pé atrás em relação a qualquer "dica" que você veja no LinkedIn.
Usando o ArrayPool
E sem mais delongas vamos ao básico, como usar o ArrayPool?
using System.Buffers;
byte[] buffer = ArrayPool<byte>.Shared.Rent(20);
// Utilizar o array
ArrayPool<byte>Shared.Return(buffer); // Provavelmente você quer isso dentro do finally.
Com isso, poderia-se afirmar: E simples assim, foi obtido um array de bytes com tamanho de 20 para ser utilizado sem alocação real. Como já dizia o poeta, nem tudo que brilha é ouro.
Se você fizer Console.WriteLine(buffer.Length) a saída será 32, ou seja, pedimos por um array de tamanho 20 e recebemos um array de tamanho 32. O motivo para isso é bem simples, o ArrayPool guarda arrays com tamanho de base 2 (2, 4, 8, 16, etc) então ele vai te retornar um array que tenha pelo menos o valor requisitado.
A outra afirmativa, que pode gerar confusão é o "sem alocação real". Existe uma alocação sim, o ArrayPool cria novos arrays sob demanda. Então, se não existir um com o valor desejado, ele será criado e disponibilizado. Se esse array nunca voltar para o pool outro será criado, neste cenário sempre será alocado um novo array. Agora que aprendemos a usar, basicamente, vamos as armadilhas.
Custo de uso de um pool
Como eu disse, nem sempre essa estratégia de pooling é efetiva. Quando o objeto é muito pequeno, é mais barato deixar o GC lidar com isso. Por conta disso o menor array possível de se obter do ArrayPool é de tamanho 16. Com isso, se o cenário precisar de um array menor do que isso é melhor nem usar o ArrayPool, e dependendo de como você usar, o JIT pode otimizar e manter o array na memória stack.
ArrayPool pode te entregar array com lixo
Se o software está rodando há bastante tempo e fazendo uso do ArrayPool, é bem provável que ele te entregue arrays com lixo de memória. Isso acontece pelo fato do ArrayPool não limpar os arrays, por padrão, quando são devolvidos. Além disso, ao alocar novos arrays, eles podem não são zerados, para ter um ganho de performance. Para ilustrar melhor, olhe o seguinte cenário:
var buffer = ArrayPool<byte>.Shared.Rent(42); // buffer.Length == 64
ProcessMessageFromDevice(buffer); // Vai preencher do índice 0 a 41
foreach(var data in buffer)
{
SendToController(data); // vai percorrer do índice 0 a 63
}
ArrayPool<byte>.Shared.Return(buffer);
Consegue ver o problema? Exatamente, o foreach vai percorrer todo o array, ou seja, todas as 64 posições. Até o índice 41 é garantido que os dados estarão de acordo com as regras de negocio, mas depois desse índice não existe garantia. Como se resolve isso? Pode-se usar um for ou while, ou manter o foreach e usar o Take(n) do LINQ.
ArrayPool pode causar memory leak
Em alguns casos o uso de ArrayPool pode causar memory leak, a ideia de se ter um pool é manter o(s) objeto(s) vivo(s), isso também é válido para os tipos que eles seguram. Em outras palavras, se o array tiver um ReferenceType ou um ValueType que tenha um ReferenceType isso vai causar um memory leak, se for apenas ValueType não vai ocorrer um leak, no caso dos exemplos aqui ArrayPool<byte> não vai ter esse problema. Agora se for algo do tipo
//ReferenceType
public class Batata
{
}
//ValueType que aponta para um ReferenceType
public struct Banana
{
public string Value;
public Banana(string v) => Value = v;
}
var buffer1 = ArrayPool<Batata>.Shared.Rent(42);
var buffer2 = ArrayPool<Banana>.Shared.Rent(42);
for(var i = 0; i < 42; i++)
{
buffer1[i] = new();
buffer2[i] = new(i.ToString());
}
ArrayPool<Batata>.Shared.Return(buffer1);
ArrayPool<Banana>.Shared.Return(buffer2);
Nesse caso, quando retornar para o pool o array vai existir e manterá referências fortes para os objetos Batata e Banana, causando assim um memory leak, pois o GC não marcará esses objetos para serem coletados.
No caso da struct, o objeto que vai causar o memory leak é a
string.
E como resolver isso? Limpando o array durante a devolução, o método return aceita um bool para indicar se o array deve ser limpo ou não. Para evitar o memory leak basta trocar as duas últimas linhas por:
ArrayPool<Batata>.Shared.Return(buffer1, clearArray: true);
ArrayPool<Banana>.Shared.Return(buffer2, clearArray: true);
Mas vem a pergunta, por que o padrão não é limpar o array sempre ao devolver? Porque fazer essa limpeza pode afetar a performance negativamente ( O(n) ), então por isso os valores não são limpos, e esse gerenciamento fica a cargo de quem os utiliza. Também numa base de código grande isso pode ficar complicado de gerenciar, então se tiver dúvidas ou dependendo do cenário de uso, é possível deixar essa decisão para o runtime, com o método RuntimeHelpers.IsReferenceOrContainsReferences<T>(). Aqui você consegue ver um exemplo de uso, na biblioteca ZLinq.
Não deixar o array sair do escopo
O array obtido pelo ArrayPool não deve sair do escopo do método que está sendo usado. Ou seja, dar um return no array vai tornar ainda mais difícil devolvê-lo, fazendo com que o propósito do pool não seja cumprido.
public int[] Do()
{
var buffer = ArrayPool<int>.Shared.Rent(16);
Compute(buffer);
return buffer; // Como esse array vai ser devolvido?
}
Não fica claro para quem consome o método Do que o array veio de um pool, então, provavelmente, ele não será retornado para o pool.
Não é uma boa prática retornar qualquer array para o pool, se for um array inválido uma exception será lançada.
Agora, se o array não sair do escopo do método é perfeitamente aceitável usá-lo em outros métodos.
// Não usar em prod.
public void ReadMessage(Stream stream)
{
var buffer = ArrayPool<byte>.Shared.Rent(1024);
using var memoryStream = new MemoryStream();
SaveStreamIntoAFile(memoryStream, buffer);
ArrayPool<byte>.Shared.Return(buffer); // Você certamente vai querer colocar isso no finally
}
Nesse caso, o buffer foi passado para o método SaveStreamIntoAFile e pode ser passado para outros métodos dentro, porém ele nunca vai sair do escopo do ReadMessage, então é garantido que ele poderá ser retornado ao ArrayPool.
Conclusão
A ideia desse artigo é mostrar que ArrayPool não é pra ser usado em todo lugar e pode causar mais problemas do que resolver. A ideia não é desencorajar o uso dessa ferramenta, e sim trazer uma luz aos vários detalhes dessa API, que podem causar problemas no software caso sejam ignorados. Em alguns pontos do seu código vai ser claro quando usar, e quando isso acontecer use. E quando não for claro, profile seu código para ter certeza de que o uso do ArrayPool vai trazer benefícios, mas na dúvida não utilize. Caso queira se aprofundar um pouco mais recomendo esse vídeo do # Stephen Toub sobre ArrayPool.
P.S.: Se você está preocupad@ com a possível pressão de memória gerada pelos arrays dentro do
ArrayPool, pode ficar tranquil@. Existem algumas políticas de limpeza e uma delas é a pressão de memória, caso haja, oArrayPoolvai remover alguns arrays para serem coletados pelo GC.
Top comments (0)