Коллекции GC при создании Enumerator из List‹T› и IList‹T›

Я читал этот комментарий Касабланки в https://softwareengineering.stackexchange.com/a/411324/109967:

Здесь есть еще одно предостережение: типы значений упаковываются в кучу, если доступ к ним осуществляется через интерфейс, поэтому при перечислении через IList или IEnumerable вам все равно придется выделять кучу. Вам нужно будет удерживать конкретный экземпляр List, чтобы избежать выделения.

Я хотел проверить эту теорию с помощью этого консольного приложения .NET 5 (я пробовал переключаться между List<T> и IList<T>):

using System;
using System.Collections.Generic;

namespace ConsoleApp10
{    
    class Program
    {
        static void Main(string[] args)
        {
            IList<int> numbers = new List<int> { 0, 1, 2, 3, 4, 5 };
            //List<int> numbers = new List<int> { 0, 1, 2, 3, 4, 5 };

            for (int i = 0; i < 2000; i++)
            {
                foreach (var number in numbers)
                {

                }
            }
            GC.Collect();
            Console.WriteLine("GC gen 0 collection count = " + GC.CollectionCount(0)); //Always 1
            Console.WriteLine("GC gen 1 collection count = " + GC.CollectionCount(1)); //Always 1
            Console.WriteLine("GC gen 2 collection count = " + GC.CollectionCount(2)); //Always 1
                
        }
    }
}

Похоже, что все поколения заполняются и проходят GCed.

Если List<T> использует структуру Enumerator, то не должно происходить никаких выделений кучи до тех пор, пока не будет вызван GC.Collect() (после этого при вызовах Console.WriteLine() создаются строковые объекты, но это происходит после запуска GC).

Вопрос 1. Почему происходит выделение кучи при использовании List<T>?

Вопрос 2: я понимаю, что будет выделение кучи из-за ссылочного типа Enumerator, используемого при итерации List<T> через интерфейс IList<T> (при условии, что комментарий в связанном вопросе верен), но почему коллекция происходит во всех 3 поколениях?

2000 объектов Enumerator — это множество объектов, но как только цикл foreach завершится, он будет готов к сборке мусора, потому что после завершения foreach ничего не ссылается на объект. Почему объекты доходят до Gen 1 и Gen 2?


person David Klempfner    schedule 05.06.2021    source источник
comment
Я не думаю, что комментарий из Касабланки правильный. Это правда, что когда вы получаете доступ к типам значений через интерфейс, они помещаются в кучу. Но это относится к таким вещам, как IFormattable formattable = 1. Это не относится к тому, находится ли тип значения внутри другого объекта и на этот объект ссылаются через интерфейс. Следовательно, foreach-обработка List‹int› и IList‹int› должна вести себя одинаково.   -  person ckuri    schedule 05.06.2021
comment
@ckuri Неверно. List<T> имеет перечислитель который является структурой, но если вы обращаетесь к нему как IEnumerable<T>, он упаковывается   -  person canton7    schedule 05.06.2021
comment
@David Ты неправильно истолковываешь то, что тебе показывает GC.CollectionCount. Он показывает, сколько раз было собрано это поколение. GC.Collect() вызывает сбор всех поколений. Итак, все, что вы видите, это то, что сбор произошел, потому что вы позвонили GC.Collection(). Вы ничего не видите о том, были ли выделены какие-либо объекты. Используйте BenchmarkDotNet с MemoryDiagnoser   -  person canton7    schedule 05.06.2021
comment
Является ли строка Console.WriteLine(number); существенной частью проблемы или это просто ненужное усложнение, которое можно удалить?   -  person Theodor Zoulias    schedule 05.06.2021
comment
Верно: это также будет вызывать выделение бокса и выделение строки при каждом вызове.   -  person canton7    schedule 05.06.2021
comment
@TheodorZoulias Я думал, что без него циклы будут оптимизированы. Но теперь я понимаю, что это происходит только в релизной сборке. Я удалю это.   -  person David Klempfner    schedule 06.06.2021


Ответы (1)


Думаю, вас смущает то, что вам показывает GC.CollectionCount().

Каждый раз, когда вы вызываете GC.Collect(), вы форсируете полную коллекцию всех поколений. Затем вы вызываете GC.CollectionCount(), который показывает, что все поколения только что собраны. Вы просто наблюдаете эффект от звонка GC.Collect()!

Это правда, что версия IList выделяет счетчики, но они быстро умирают в gen0.

Надлежащим инструментом для изучения такого рода вещей является BenchmarkDotNet с MemoryDiagnoser.

Я собрал простой тест:

public static class Program
{
    public static void Main()
    {
        BenchmarkRunner.Run<Benchmarks>();
    }
}

[MemoryDiagnoser]
public class Benchmarks
{
    [Benchmark]
    public int IList()
    {
        int sum = 0;
        IList<int> numbers = new List<int> { 0, 1, 2, 3, 4, 5 };

        for (int i = 0; i < 2000; i++)
        {
            foreach (var number in numbers)
            {
                sum += number;
            }
        }
        return sum;
    }

    [Benchmark]
    public int List()
    {
        int sum = 0;
        List<int> numbers = new List<int> { 0, 1, 2, 3, 4, 5 };

        for (int i = 0; i < 2000; i++)
        {
            foreach (var number in numbers)
            {
                sum += number;
            }
        }
        return sum;
    }
}

Это дало результаты:

BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19042.985 (20H2/October2020Update)
Intel Core i5-6300U CPU 2.40GHz (Skylake), 1 CPU, 4 logical and 2 physical cores
.NET SDK=5.0.300-preview.21180.15
  [Host]     : .NET 5.0.5 (5.0.521.16609), X64 RyuJIT
  DefaultJob : .NET 5.0.5 (5.0.521.16609), X64 RyuJIT
Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
IList 149.42 μs 2.902 μs 3.563 μs 51.0254 - - 80,128 B
List 48.98 μs 0.961 μs 1.524 μs - - - 128 B

Выделение 128 байт, общее для обоих, будет самим экземпляром List<T>. Кроме этого, видите, как версия IList выделяет примерно на 80 КБ больше, вызывая 51 новую коллекцию gen0 на 1000 операций?

person canton7    schedule 05.06.2021
comment
Меня не смущал GC.CollectionCount(). Я был сбит с толку тем, что сделал GC.Collect(). Я думал, что он будет выполнять сбор только в том случае, если есть объекты, которые нужно собрать. Спасибо за Ваш ответ. Это хорошо объясняет. - person David Klempfner; 06.06.2021
comment
Когда вы говорите на 1000 операций, вы имеете в виду 2000? - person David Klempfner; 06.06.2021
comment
Нет. Этот столбец — количество коллекций на 1000 операций, согласно документам BenchmarkDotNet. Это не связано с вашей петлей. - person canton7; 06.06.2021
comment
Почему он показывает на 1000 операций? Почему бы просто не показать, сколько коллекций было во время работы приложения? - person David Klempfner; 06.06.2021
comment
BenchmarkDotNet вызывает эти методы List и IList много раз, но решает, сколько раз это делать, основываясь на ряде различных факторов. Число представляет собой количество коллекций GC на 1000 вызовов, предположительно потому, что 51 легче читать, чем 0,0051. см. здесь - person canton7; 06.06.2021
comment
Из этого можно сделать вывод, что сборщик мусора, вероятно, установил лимит бюджета поколения 0 в размере около 1,57 МБ (именно столько необходимо выделить в поколении 0, прежде чем сборщик мусора будет запущен), но это полностью детали реализации. - person canton7; 06.06.2021
comment
Некоторые отзывы, которые я получил от кого-то о вашем фрагменте кода: вам не следует создавать итерационный цикл в коде самостоятельно. Это параметр самого задания бенчмарка. - person David Klempfner; 15.06.2021
comment
@DavidKlempfner Я просто скопировал/вставил код ОП, чтобы показать, насколько простым может быть перевод. Согласен, надо настроить так, чтобы создание списков тоже не было частью бенчмарка. Однако для таких простых случаев, как этот, вполне вероятно, вам понадобится внутренний цикл, или BenchmarkDotNet сообщит, что эталонный тест слишком дешевый, чтобы его можно было надежно измерить. - person canton7; 15.06.2021