Utilizar os recursos da linguagem de programação e dos frameworks de forma adequada é fundamental para criação de aplicações performáticas. Algumas operações inadequadas podem ser suficientes para sacrificar até mesmo bons algoritmos. Se tratando de performance, um bom exemplo de equívoco em .NET que pode gerar dores de cabeça está no uso abusivo do método ToList.
O método ToList tem como objetivo materializar uma lista de elementos em memória. Na prática, ele retorna uma nova instância de List<TSource> a partir de um IEnumerable<TSource>. Geralmente, essa é uma operação rápida, porém quando trabalhamos com aplicações onde a performance é atributo de qualidade crítico, entender o impacto de sua utilização é fundamental para evitar o uso equivocado e consequentemente colocar a performance da aplicação em risco.
Vamos consultar a implementação do método ToList, no GitHub da Microsoft e entender como realmente funciona a materialização dessa lista:
public static List<TSource> ToList<TSource>(this IEnumerable<TSource> source) { if (source == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source); } return source is IIListProvider<TSource> listProvider ? listProvider.ToList() : new List<TSource>(source); }
Perceba alguns aspectos importantes sobre o método ToList observando sua implementação:
- Com o objetivo de otimizar performance, o método tratará de forma diferente coleções do tipo IIListProvider, executando uma implementação específica de ToList para cada tipo de operação (Select, Where, OrderBy, etc). Para coleções que não são do tipo IIListProvider, uma lista é criada passando o enumerable fonte como parâmetro. Independente do tipo da coleção, uma nova instância de List<TSource> é criada e os objetos são copiados para essa nova lista, gerando pressão sobre a memória;
- Caso o tamanho da lista ultrapasse 85.000 bytes, o impacto negativo é agravado, pois a mesma será alocada na Large Object Heap (LOH), tornando o processo de desalocação de memória mais custoso;
- Um ponto especial em relação a operações como Select e Where, é que ao chamar o método ToList interno, uma nova instância de List<TSource> é criada sem informar uma capacidade (parâmetro capacity), resultando em um array de 4 posições. Considerando que não é possível redimensionar arrays em .NET, teremos uma pressão sobre o garbage collector, pois toda vez que o limite do array for alcançado, um novo array será criado com o dobro da capacidade do anterior, todos elementos serão copiados para o novo array e o array anterior será descartado.
Meça sempre
Para sabermos o quanto a utilização desse recurso impacta a performance, faremos a avaliação de uma operação que utiliza ToList e outra que não utiliza a partir de um enumerable já criado. Para isso, utilizaremos a versão .NET Core 3.1.14 e a ferramenta BenchmarkDotNet.
Métodos utilizados na comparação:
[Benchmark] public void UsingToList() { var products = _products .Where(x => x.Type == ProductType.Electronic) .ToList(); foreach (var product in products) { Console.WriteLine("Product name is: {0}.", product.Name); } } [Benchmark] public void NotUsingToList() { var products = _products .Where(x => x.Type == ProductType.Electronic); foreach (var product in products) { Console.WriteLine("Product name is: {0}.", product.Name); } }
Resultado da comparação com uma coleção de 10.000 objetos e posteriormente uma coleção de 50.000 objetos:
O tempo de processamento não aponta uma diferença significativa nesse cenário, contudo a diferença na necessidade de memória é expressiva. Fica claro o impacto negativo ao se utilizar o método ToList, variando de acordo com a quantidade de objetos pertencentes à coleção e seus tamanhos. É importante destacar que em operações que demandam alta performance ou em infraestrutura com recursos limitados, essa alocação excessiva de memória pode ocasionar em uma OutOfMemoryException, interrompendo a execução da aplicação.
Cuidado com ToList em conjunto de ORM’s
Considerando a análise realizada do método ToList, cuidado com sua utilização em conjunto de ORM’s. Observe o código abaixo:
public IEnumerable<Product> GetByType(ProductType type) { return _dbContext .Products .ToList() .Where(product => product.Type == type); }
Perceba que neste exemplo estamos materializando todos os objetos em memória, para depois executar um filtro nesta lista materializada. Além do consumo de memória, estamos falando de uma consulta mais pesada no banco de dados e uma pressão maior na rede, já que todos os registros desta tabela serão trafegados para a aplicação. Evite materializar as listas de maneira desnecessária em memória.
Mas então, quando utilizar ToList?
Ao consultarmos a documentação da Microsoft, nos deparamos com a seguinte observação:
A utilização do método ToList pode ser adequada para cenários onde o resultado de um Enumerable será utilizado mais de uma vez, fazendo com que a query evaluation não seja executada múltiplas vezes. Neste caso, o valor retornado de ToList serve como um cache para os resultados da query, evitando processamento desnecessário. Ainda assim é importante observar que em situações onde a query retorna uma grande quantidade de objetos, o uso do ToList pode impactar em uso excessivo de memória, valendo avaliar o trade-off em cada situação de uso.
É essencial conhecer os recursos disponíveis na linguagem de programação, nos frameworks e o impacto de cada escolha dentro do nosso código. Uma simples escolha ingênua pode causar uma enorme dor de cabeça. A documentação da Microsoft, o código de referência do framework e livros como C# in depth e Writing High-Performance .NET Code se tornam obrigatórios quando pensamos em criar aplicações eficientes.
Parabéns, ótimo conteúdo!
Muito bom!!! Não havia lido algo que me chamasse tanto a atenção para este fato.
muito bom
muito bom! otima explicacao.
Boa Castilho! Ótimo conteúdo.
Excelente Raphael! como me inscrevo para receber insights em meu e-mail?
Excelente explicação. Obrigado.