Start Debugging

Итератор yield return или возврат List<T>: сколько на самом деле стоит метод с IEnumerable в C#

Метод с yield return стоит один 40-байтовый объект конечного автомата на вызов, сколько бы элементов он ни выдавал; List<T> сразу выделяет весь внутренний массив. Замеры на .NET 10.0.10: итератор выигрывает по памяти и при раннем выходе, List<T> выигрывает, когда вызывающий код перечисляет больше одного раза, нуждается в Count или вызывает ToList().

Короткий ответ: метод с yield return выделяет ровно один небольшой объект на вызов (40 байт для простого цикла с IEnumerable<int> на .NET 10.0.10), независимо от того, выдаёт он 10 элементов или миллион. Метод, который строит и возвращает List<T>, выделяет список плюс внутренний массив размером с результат: 4,056 байт для 1,000 значений int при заранее заданной ёмкости списка и 8,424 байт без неё. Выбирайте итератор, когда вызывающий код перечисляет последовательность один раз (один foreach по итератору был примерно в 2 раза быстрее, чем заполнение и чтение списка при 1,000 элементов), может остановиться раньше или последовательность большая либо бесконечная. Выбирайте List<T> (и указывайте это в типе возвращаемого значения, List<T> или IReadOnlyList<T>), когда вызывающему коду нужны Count, индексация или более одного прохода, потому что каждый дополнительный проход по итератору заново выполняет тело вашего метода.

Всё ниже измерено на C# 14 и .NET 10.0.10 (SDK 10.0.302) на Apple M4; время замерялось через BenchmarkDotNet 0.15.8, а выделения памяти на вызов через GC.GetAllocatedBytesForCurrentThread().

Сравнение с первого взгляда

Итератор yield returnList<T>, возвращённый как IEnumerable<T>
Выделения на вызов (.NET 10.0.10, int)40 B, постоянно (иногда 0 B, если JIT встроит вызов)32 B + внутренний массив (4,056 B для 1,000 значений int)
Работа в момент вызованикакой, даже проверок аргументоввся
Работа, если вызывающий код остановился после 1 элемента1 элементвсе элементы
Второй foreachзаново выполняет тело методачитает сохранённые элементы
Count()проходит всю последовательностьO(1) через ICollection<T>
TryGetNonEnumeratedCountfalsetrue
ToList() / ToArray()рост удвоением / копирование в массив точного размераодно копирование точного размера
Исключенияпри первом MoveNext()в момент вызова
Потокобезопасность результатановый конечный автомат на каждое перечислениебезопасно читать из многих потоков

Во что компилятор превращает yield return

Это итератор, который используется во всей статье:

// .NET 10.0.10, C# 14
public static IEnumerable<int> Iterator(int n)
{
    for (int i = 0; i < n; i++)
        yield return i * 2;
}

Roslyn переписывает метод в скрытый класс, здесь он называется <Iterator>d__0, а тело метода превращается в “выделить этот класс, сохранить аргументы, вернуть его”. Рефлексия по сгенерированному типу показывает, что именно внутри:

Src+<Iterator>d__0
  field Int32 <>1__state
  field Int32 <>2__current
  field Int32 <>l__initialThreadId
  field Int32 n
  field Int32 <>3__n
  field Int32 <i>5__2
  implements IEnumerable`1, IEnumerable, IEnumerator`1, IDisposable, IEnumerator

Шесть полей int плюс 16-байтовый заголовок объекта и указатель на таблицу методов дают 40 байт. Каждая локальная переменная, переживающая yield return, становится полем (<i>5__2 это переменная цикла), а каждый параметр хранится дважды: <>3__n сохраняет исходное значение, чтобы последовательность можно было перечислить снова, а n это рабочая копия. Метод с параметром string и локальной переменной DateTime получит поля побольше, но форма никогда не зависит от того, сколько элементов вы выдаёте. В этом и смысл: память O(1) относительно числа элементов.

Один и тот же объект является и IEnumerable<T>, и, при первом вызове GetEnumerator() из создавшего его потока, IEnumerator<T>. Поэтому обычный foreach (var x in Iterator(n)) стоит одно выделение, а не два.

Версия с List<T> это код, который большинство людей пишет первым:

// .NET 10.0.10, C# 14
public static List<int> ListCap(int n)
{
    var list = new List<int>(n);
    for (int i = 0; i < n; i++)
        list.Add(i * 2);
    return list;
}

Здесь стоимость это 32-байтовый объект List<int> плюс int[] из n элементов (24 байта заголовка и длины, затем по 4 байта на элемент). Уберите n из конструктора, и список будет расти 4, 8, 16, …, 1024, оставляя каждый промежуточный массив сборщику мусора.

Выделения памяти на вызов, замеры

Каждый случай запускался 200,000 раз, чтобы код дошёл до tier-1, затем усреднялся по 1,000 вызовам:

ЭлементовИтератор, foreachList<T>(n), foreachList<T>(), foreachИтератор .ToList()Итератор .ToArray()
040 B32 B32 B72 B40 B
140 B64 B72 B112 B72 B
1040 B96 B216 B256 B104 B
1,00040 B4,056 B8,424 B8,464 B4,064 B

Бросаются в глаза три вещи.

Во-первых, при нуле элементов список на самом деле дешевле (32 B против 40 B), а при одном элементе разница крошечная. Для коротких ограниченных результатов память не повод выбирать то или другое.

Во-вторых, Iterator(n).ToList() это худший столбец. Итератор не реализует ICollection<T>, поэтому ToList() не может заранее задать ёмкость и растёт удвоением, точно как список без ёмкости, плюс 40-байтовый конечный автомат. Если каждый вызывающий код сразу вызывает ToList() на вашем итераторе, вы платите за оба подхода. Возвращайте вместо этого List<T> с заранее заданной ёмкостью.

В-третьих, ToArray() на итераторе в .NET 10 почти не создаёт лишнего: 4,064 B для 1,000 значений int, всего на 8 байт больше, чем точный массив плюс итератор. Начиная с .NET 9, ToArray() буферизует последовательности неизвестной длины в сегментах на стеке и копирует один раз в массив точного размера; это изменение я разбирал в статье ToList vs ToArray on .NET 9. Если вызывающему коду нужно материализовать итератор, ToArray() это более дешёвый вызов.

Что JIT в .NET 10 убирает за вас

Возврат List<T>, типизированного как IEnumerable<T>, раньше стоил ещё одного выделения: foreach по интерфейсу вызывает IEnumerable<T>.GetEnumerator(), который упаковывает структуру List<T>.Enumerator (40 байт на 64-битной платформе). На .NET 10.0.10 случай “List как IEnumerable” выделяет те же 64 B, что и конкретный список при одном элементе. Повторный запуск замера с DOTNET_JitObjectStackAllocation=0 возвращает упаковку (104 B), что показывает: управляемая девиртуализация JIT плюс размещение объектов на стеке, часть работы по деабстракции перечислителей в .NET 10, теперь размещает этот перечислитель на стеке. При нуле элементов упаковки нет даже с выключенным параметром, потому что List<T> отдаёт общий пустой перечислитель при Count == 0.

40 байт итератора не изменились в случаях с foreach, с escape-анализом и без него. Конечный автомат возвращается из вашего метода, то есть по определению “убегает”, и JIT может разместить его на стеке, только если встроит метод-итератор и всё, что его потребляет, в один вызывающий метод. Для некоторых вызовов LINQ в бенчмарке ниже это произошло, но ненадёжно.

Время: где побеждает каждый вариант

BenchmarkDotNet 0.15.8, задание по умолчанию, .NET 10.0.10 Arm64 RyuJIT, Apple M4. Каждый метод строит последовательность и потребляет её внутри бенчмарка, поэтому строки “List” включают заполнение списка. Среднее время, выделения в скобках:

БенчмаркN = 10N = 1,000N = 100,000
Итератор, foreach10.7 ns (40 B)580 ns (40 B)56.9 us (40 B)
List<T>(n), foreach12.7 ns (96 B)1,004 ns (4,056 B)116.3 us (400,140 B)
List<T>(), foreach30.1 ns (216 B)1,214 ns (8,424 B)145.8 us (1,049,168 B)
List<T>(n) как IEnumerable<T>, foreach13.2 ns (96 B)1,148 ns (4,056 B)114.6 us (400,140 B)
Итератор, .First()2.1 ns (0 B)2.1 ns (0 B)2.0 ns (0 B)
List<T>(n), .First()11.4 ns (96 B)666 ns (4,056 B)77.3 us (400,140 B)
Итератор, .ToList()34.1 ns (216 B)1,452 ns (8,424 B)166.9 us (1,049,168 B)
Итератор, .Count()7.3 ns (0 B)541 ns (0 B)53.5 us (0 B)

При одном проходе итератор быстрее при любом размере: примерно в 1.2 раза при 10 элементах и в 2 раза при 1,000 и 100,000. Это удивляет тех, кто ожидает, что “вызов интерфейса на каждый элемент” проиграет “списку”. Версия со списком выполняет тот же цикл, чтобы заполнить список, затем второй цикл, чтобы его прочитать, и платит за массив; итератор выполняет один цикл. При 100,000 значений int массив заранее заданного списка размером 400,140 байт превышает порог в 85,000 байт, поэтому попадает в Large Object Heap, и каждый вызов вызывает сборки Gen2 в запуске BenchmarkDotNet.

Ранний выход это то место, где разрыв становится абсурдным: First() на итераторе стоит 2 ns независимо от размера, потому что вычисляет один элемент. Версия со списком вычисляет все 100,000, прежде чем First() увидит хоть один.

Две строки, где итератор проигрывает, это те, где всё равно нужен весь результат. Iterator(n).ToList() самая медленная строка в каждом столбце, медленнее, чем прямое построение списка без ёмкости, потому что добавляет вызовы интерфейса к росту удвоением. А Count() на итераторе здесь быстр именно потому, что потребляет последовательность; если вы вызываете Count(), а затем foreach, вы запускаете метод дважды, и выигрывает O(1) Count у списка.

0 B в строках итератора First() и Count() это снова JIT в .NET 10: в этих бенчмарках он встроил метод-итератор и вызов LINQ и разместил конечный автомат на стеке. Мой замер выделений видел то же самое в одних местах вызова и обычные 40 B в других, а DOTNET_JitObjectStackAllocation=0 везде возвращал 40 B. Считайте это бонусом, а не тем, вокруг чего стоит проектировать.

Итератор иногда использует себя повторно

Одна деталь объясняет удивительное число в замере: двойное перечисление одного и того же итератора выделило 40 байт всего, а не 80. Сгенерированный GetEnumerator() возвращает this, когда поле состояния равно -2 (только что создан) и вызывающий код находится в создавшем потоке. Дизассемблирование сгенерированного Dispose() на SDK 10.0.302 показывает, что это просто this.<>1__state = -2, так что, как только foreach завершается или прерывается через break и освобождает перечислитель, объект возвращается в начальное состояние, и следующий foreach в том же потоке получает тот же экземпляр:

// .NET 10.0.10, C# 14 (SDK 10.0.302)
var seq = Iterator(5);
foreach (var x in seq) { if (x == 2) break; }
var again = seq.GetEnumerator();
Console.WriteLine(ReferenceEquals(seq, again)); // True: reused after Dispose

var fresh = Iterator(5);
var e1 = fresh.GetEnumerator();
e1.MoveNext();
var e2 = fresh.GetEnumerator();
Console.WriteLine(ReferenceEquals(fresh, e2));  // False: e1 still owns the instance

Второй перечислитель, взятый, пока первый ещё жив, или взятый из другого потока, получает новый экземпляр, инициализированный из сохранённых копий параметров <>3__. Для корректности об этом думать не нужно, но это значит, что “итератор, перечисленный дважды” дёшев в байтах. В работе он не дёшев, и об этом следующий пункт.

Подводные камни, которые решают за вас

Каждое перечисление заново выполняет ваш метод

Итератор не хранит элементов. Второй foreach, Count() с последующим foreach или Any() с последующим ToList() заново выполняют тело метода, включая любой вызов базы данных, чтение файла или Console.WriteLine внутри него:

// .NET 10.0.10, C# 14
static IEnumerable<string> ReadNames(string path)
{
    Console.WriteLine($"opening {path}");
    foreach (var line in File.ReadLines(path))
        yield return line.Trim();
}

var names = ReadNames("names.txt");
if (names.Any())                      // opening names.txt
    Console.WriteLine(names.Count()); // opening names.txt (again)

Анализаторы .NET помечают это правилом CA1851: Possible multiple enumerations of IEnumerable collection, но по умолчанию оно выключено. Если вашим вызывающим регулярно нужны два прохода, возвращайте список. Если вы унаследовали IEnumerable<T> и не можете понять, безопасно ли проходить его дважды, проверки из этой статьи об определении материализованного IEnumerable это самое близкое, что есть в BCL.

Проверка аргументов откладывается

Ничто в теле итератора не выполняется до первого MoveNext(), включая охранные условия в начале:

// .NET 10.0.10, C# 14
static IEnumerable<int> Take(IEnumerable<int> source, int count)
{
    ArgumentNullException.ThrowIfNull(source); // runs on first MoveNext, not here
    foreach (var x in source)
    {
        if (count-- <= 0) yield break;
        yield return x;
    }
}

var q = Take(null!, 3);   // no exception
foreach (var x in q) { }  // ArgumentNullException thrown here

Трассировка стека тогда указывает на foreach, возможно далеко от неверного вызова. Стандартное решение, и причина, по которой собственные операторы LINQ в BCL бросают исключения в момент вызова, это разделить метод на два: публичный метод без итератора, который проверяет аргументы, и приватный итератор (подойдёт локальная функция), который выдаёт элементы:

// .NET 10.0.10, C# 14
static IEnumerable<int> Take(IEnumerable<int> source, int count)
{
    ArgumentNullException.ThrowIfNull(source); // throws at call time
    ArgumentOutOfRangeException.ThrowIfNegative(count);
    return Core(source, count);

    static IEnumerable<int> Core(IEnumerable<int> source, int count)
    {
        foreach (var x in source)
        {
            if (count-- <= 0) yield break;
            yield return x;
        }
    }
}

У метода с List<T> такой ловушки нет: он выполняется до конца или бросает исключение, прежде чем вернуться.

finally выполняется, только если кто-то освобождает перечислитель

Блоки try/finally и using внутри итератора выполняются, когда перечисление завершается или когда перечислитель освобождается. foreach всегда освобождает, в том числе при break и при исключениях. Код, который вызывает GetEnumerator() и MoveNext() вручную и забывает Dispose(), оставляет файлы и соединения открытыми, пока сборщик мусора не финализирует то, чем они владели. Список после возврата не удерживает никаких ресурсов.

Рекурсивные итераторы квадратичны

yield return не умеет пробрасывать вложенную последовательность, поэтому рекурсивный обход дерева, написанный как foreach (var child in Walk(node.Left)) yield return child;, выделяет один конечный автомат на узел и проталкивает каждый элемент через каждый уровень: O(n x depth) вызовов MoveNext. Для глубоких деревьев используйте явный Stack<T> внутри одного итератора или заполняйте один List<T>, передаваемый вниз по рекурсии.

Тип возвращаемого значения это обещание

Возврат IEnumerable<T> из метода, который на самом деле строит List<T>, скрывает тот факт, что Count бесплатен, и приглашает вызывающий код звать ToList() “на всякий случай”, копируя данные второй раз (случай List.ToList() на 8,112 B при 1,000 элементов в таблице выделений выше, против 4,056 B для самого списка). Если вы материализуете, возвращайте List<T>, IReadOnlyList<T> или массив, чтобы вызывающий код это видел. Если вы потоково отдаёте, возвращайте IEnumerable<T> и документируйте, что он ленивый. Для данных из базы данных или сети ни то ни другое не подходит; IEnumerable vs IAsyncEnumerable vs IQueryable объясняет, когда вместо этого стоит отдавать данные потоком асинхронно, а статья о том, для чего нужен IAsyncEnumerable глубже раскрывает await foreach.

Что возвращать

Используйте итератор yield return, когда:

Возвращайте List<T> (с типом List<T> или IReadOnlyList<T>), когда:

Мой выбор по умолчанию для публичных API это второй список: материализованный и честно типизированный. К yield return я прибегаю, когда реальное требование это ранний выход или память, и тогда я каждый раз выношу проверку в обёртку без итератора.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад