Спор о том, вредит ли LINQ скорости, тянется с ноября 2007 года, когда C# 3.0 вместе с .NET Framework 3.5 принёс в язык запросы к коллекциям. С тех пор на ревью сталкиваются две партии. Одна повторяет, что читаемость дороже наносекунд, и спокойно пишет цепочки Where, Select и OrderBy даже в коде, который крутится тысячи раз в секунду. Другая вручную разворачивает каждый фильтр в for, тычет в аллокации и не верит никому на слово. Любопытно, что правы обе, просто на разных участках. Разница между запросом и циклом не миф, а измеримая величина с конкретными причинами: конвейер итераторов, упаковка структурных перечислителей, замыкания и вызовы через интерфейс. Ниже эти механизмы разобраны по частям, с цифрами и правилами, где цикл окупается, а где нет.

Что LINQ платит за удобство итераторами, упаковкой и замыканиями

Цепочка вида items.Where(filter).Select(project).Count() выглядит как одна операция, а на самом деле это конвейер из отдельных объектов. Каждый оператор оборачивает предыдущий источник в собственный перечислитель. Внутри библиотеки System.Linq метод Where устроен примерно так:

// упрощённо суть Where из исходников System.Linq
static IEnumerable<T> Where<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (T item in source)
        if (predicate(item))
            yield return item;
}

Слово yield превращает метод в конечный автомат с методом MoveNext. Один шаг внешнего оператора тянет за собой шаг внутреннего, тот обращается к источнику, и так до дна. Конвейер из трёх операторов на каждый элемент даёт несколько вложенных вызовов MoveNext плюс вызовы делегатов, и JIT чаще всего не может ни встроить их инлайн, ни применить SIMD.

Второй механизм спрятан в источнике. Перечислитель List<T> является структурой, и обычный foreach по списку работает без аллокаций. Но операторы LINQ принимают IEnumerable<T>, то есть интерфейс, и при переходе от структуры к интерфейсу происходит упаковка в кучу. Одна упаковка на каждый уровень конвейера.

Третий механизм связан с лямбдами. Выражение x => x.Amount > threshold захватывает внешнюю переменную threshold, поэтому компилятор создаёт класс-замыкание, экземпляр этого класса и объект делегата, то есть две аллокации на каждый вызов метода. Лямбда без захватов дешевле: компилятор кэширует делегат один раз, а с C# 9 слово static в лямбде явно запрещает захваты ещё на этапе компиляции.

Сколько это в байтах? На x64 один проход list.Where(p).Count() по List<int> обычно оставляет в куче порядка сотни-полутора сотен байт: объект итератора, упакованный перечислитель и, при захвате переменных, замыкание с делегатом. Для кода, который выполняется раз в минуту, цена равна нулю. Для кода, который вызывается десять тысяч раз в секунду, это уже мегабайты мусора в нулевом поколении каждую секунду. Нулевое поколение чистится быстро, но сборка останавливает потоки и добавляет задержку там, где её не ждали.

Замеры фильтрации миллиона элементов и типичная разница в разах

Теория без цифр превращается в веру, поэтому спор стоит начинать с бенчмарка. Стандартный инструмент BenchmarkDotNet с атрибутом MemoryDiagnoser показывает и время, и аллокации каждого сценария:

[MemoryDiagnoser]
public class FilterBenchmarks
{
    private readonly List<int> _data =
        Enumerable.Range(0, 1_000_000).ToList();

    [Benchmark]
    public int LinqWhereCount()
    {
        return _data.Where(x => (x & 1) == 0).Count();
    }

    [Benchmark]
    public int ForLoopCount()
    {
        int count = 0;
        Span<int> span = CollectionsMarshal.AsSpan(_data);
        for (int i = 0; i < span.Length; i++)
        {
            if ((span[i] & 1) == 0) count++;
        }
        return count;
    }
}

Картина замеров на x64 и .NET 8 устойчива от машины к машине: ручной цикл с тривиальным предикатом обрабатывает миллион элементов за сотни микросекунд, LINQ-версия укладывается в миллисекунды и проигрывает в три-пять раз. В колонке Allocated у цикла стоит ноль, у запроса порядка пары сотен байт на вызов. С ростом коллекции разрыв не сглаживается: расходы на элемент постоянны.

Объяснение простое. For по Span или массиву компилятор умеет векторизовать: проверка границ убирается, несколько элементов обрабатываются одной SIMD-инструкцией. Конвейер LINQ через интерфейсы векторизовать нельзя, пока не известно, что источник является массивом. Платформа закрывает часть зазора сама: в .NET 7 методы Min и Max для целых чисел получили векторизованный путь через System.Numerics.Vector, а Sum научился через TryGetSpan вытаскивать спан из массивов и списков и идти по нему вместо конвейера интерфейсов. Работает это для простых числовых сценариев, а не для цепочки со сложным предикатом: на тривиальном фильтре конвейер доминирует и цикл выигрывает кратно, а когда предикат парсит строку или ходит в словарь, накладные наносекунды растворяются в полезной работе, и разница падает до считанных процентов.

Отложенное выполнение и двойной проход по одной коллекции

Самые дорогие ошибки с LINQ связаны не с наносекундами, а с семантикой отложенного выполнения. Запрос не выполняется в точке объявления, он выполняется в точке перечисления, и каждый новый проход по IEnumerable запускает конвейер заново:

public Report BuildReport(IEnumerable<Order> orders)
{
    var big = orders.Where(o => o.Amount > 1000);
    int count = big.Count();
    decimal total = big.Sum(o => o.Amount);
    return new Report(count, total);
}

Здесь коллекция фильтруется дважды. Если orders уже лежит в памяти списком, потеряны лишние миллисекунды. Если orders сам является ленивым перечислителем, который читает файл или ждёт страницу из сервиса, второй проход повторит всю работу источника с нуля, включая сетевые вызовы. Дефект прячется отлично: метод возвращает корректный результат, просто работает вдвое дольше. Лечение простое, материализуйте один раз:

var big = orders.Where(o => o.Amount > 1000).ToList();
int count = big.Count;
decimal total = big.Sum(o => o.Amount);

Отдельная ловушка ждёт в отладчике: раскрытие Results View у IEnumerable перечисляет последовательность и выполняет запрос со всеми побочными эффектами прямо в сессии отладки. А когда размер нужен без перебора, в .NET 6 появился TryGetNonEnumeratedCount, который достаёт счётчик из ICollection<T>, не трогая элементы.

Порядок операторов и короткие проверки вместо полного прохода

Стоимость запроса складывается и из порядка операторов. Первое правило: фильтруйте как можно раньше. Where до Select сокращает объём данных для проекции, а Where до OrderBy позволяет сортировать уже укороченный набор. OrderBy в LINQ выполняет стабильную сортировку, то есть сохраняет порядок равных элементов, но за это платится буферизацией: оператор копирует весь вход в промежуточный буфер. Сортировать миллион записей ради первой страницы из двадцати строк расточительно.

Второе правило: не читайте коллекцию целиком, если ответ известен после первого элемента. Две строки ниже отличаются принципиально:

bool hasBig = orders.Any(o => o.Amount > 1000);
int bigCount = orders.Count(o => o.Amount > 1000);

Any с предикатом выходит из конвейера на первом совпадении, Count дочитывает до конца в любом случае. Проверка наличия записи через Count(p) > 0 на большом наборе классический антипаттерн. У Count есть быстрый путь через ICollection<T>, но только в голове цепочки: Count сразу по списку не перебирает элементы, а Count после Where обязан пропустить весь набор через фильтр.

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

var activeIds = new HashSet<int>(GetActiveCustomerIds());
var visible = allOrders
    .Where(o => activeIds.Contains(o.CustomerId))
    .ToList();

Переход со списка на множество меняет стоимость проверки с сотен тысяч сравнений на одну хеш-операцию на миллионном наборе. Ни один ручной цикл по List<T> не спасёт так дёшево, как замена самой структуры.

Где LINQ уже быстрый и где переписывать его нет смысла

У LINQ есть зоны, где спорить не о чем. Это редкий код: инициализация приложения, чтение конфигурации, ежевечерний импорт. Пара сотен байт аллокаций и лишняя сотня микросекунд на фоне секунд работы не видна ни в одном профиле. Мелкие коллекции из десятков элементов живут в той же категории: накладные расходы запроса измеряются десятками наносекунд и читаются только в коде, не в профайлере.

Платформа тоже наступает на пятки циклам: в .NET 6 появились MinBy, MaxBy, DistinctBy, Chunk и TryGetNonEnumeratedCount, в .NET 7 векторизованы Min и Max для целых чисел, а Sum получил быстрый путь по спану. Динамическая профильная оптимизация PGO, включённая по умолчанию начиная с .NET 8, позволяет JIT учиться на реальной нагрузке и встраивать даже часть интерфейсных вызовов. Зазор сужается с каждым релизом, хотя аллокации конвейера полностью не исчезают.

Отдельный мир это IQueryable в Entity Framework Core: запрос транслируется в SQL и выполняется на стороне базы, а накладные расходы компиляции выражения на фоне работы СУБД ничтожны. Забота другая: убедиться, что фильтрация уходит в WHERE, а не подтягивается в память через AsEnumerable раньше времени.

Ручная замена запроса циклом спаном и пулом арендованных буферов

Когда профиль показал горячий участок, у ручного подхода есть проверенный набор. Первый и самый частый это обычный for, по списку удобнее идти через спан над его внутренним буфером, метод CollectionsMarshal.AsSpan доступен начиная с .NET 5:

Span<int> span = CollectionsMarshal.AsSpan(_data);
int total = 0;
for (int i = 0; i < span.Length; i++)
{
    if (span[i] > 0)
    {
        total += span[i];
    }
}

Спан снимает интерфейсную прослойку, а канонический цикл по Span<T> позволяет JIT убрать проверку границ и применить SIMD. Пока спан жив, в список нельзя добавлять или удалять элементы, иначе поведение не определено. Для мелких временных буферов до нескольких килобайт подходит стек через stackalloc, для больших аренда из ArrayPool<T>.Shared:

int[] rented = ArrayPool<int>.Shared.Rent(needed);
try
{
    Span<int> scratch = rented.AsSpan(0, needed);
    // тяжёлая работа без новых аллокаций
}
finally
{
    ArrayPool<int>.Shared.Return(rented);
}

Пул выдаёт буфер с запасом до степени двойки, поэтому работать надо с срезом нужной длины. Возврат в finally обязателен, иначе следующий арендатор получит чужие значения.

Если ручной цикл всё же должен вызывать предикат, вынесите логику в статическую лямбду без захватов:

private static readonly Func<int, bool> IsEven = static x => (x & 1) == 0;

Такой делегат создаётся один раз и не тянет за собой замыканий, а в коде, который вызывается тысячи раз в секунду, замыкания и есть главный источник мусора.

Практические правила выбора между запросом и циклом в горячем коде

Осталось собрать механику выбора в короткий набор правил для ревью:

  1. измерьте участок, прежде чем спорить: BenchmarkDotNet для изолированного кода и профилировщик для нагрузки, предположения о горячих точках ошибаются в разах;
  2. смотрите не только на время, но и на аллокации: счётчик alloc-rate в dotnet-counters или разность GC.GetAllocatedBytesForCurrentThread покажет цену в байтах;
  3. переписывайте в цикл код, который вызывается тысячи раз в секунду или обрабатывает миллионы элементов, например фильтр в цикле рендеринга с бюджетом 16 миллисекунд на кадр;
  4. оставляйте LINQ в редком коде, обработчиках кнопок и везде, где коллекция измеряется десятками элементов;
  5. устраняйте семантические ловушки раньше наносекунд: двойное перечисление и Count вместо Any стоят дороже любых итераторов;
  6. выбирайте структуру данных до выбора между запросом и циклом, HashSet и Dictionary дают больше любых переписываний;
  7. фиксируйте результат замера в комментарии рядом с оптимизацией, чтобы следующий разработчик не начинал спор заново.

Итог спокойный. LINQ на больших коллекциях в C# действительно платит измеримую цену: конвейер итераторов, упаковка перечислителей, замыкания и невозможность векторизации дают разрыв в три-пять раз на тривиальных фильтрах и лишние сотни байт мусора на вызов. Ручной цикл по спану с пулом буферов честно отрабатывает каждый наносекундный рубль. Разумная граница проходит не по вкусу, а по профилю: горячие пути с миллионами элементов и высокой частотой вызовов заслуживают ручной работы, остальное спокойно живёт на запросах. Измеряйте, читайте колонку Allocated, не забывайте про двойное перечисление, и вопрос превратится в инженерную процедуру на десять минут.