Спор о том, вредит ли 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;
Такой делегат создаётся один раз и не тянет за собой замыканий, а в коде, который вызывается тысячи раз в секунду, замыкания и есть главный источник мусора.
Практические правила выбора между запросом и циклом в горячем коде
Осталось собрать механику выбора в короткий набор правил для ревью:
- измерьте участок, прежде чем спорить: BenchmarkDotNet для изолированного кода и профилировщик для нагрузки, предположения о горячих точках ошибаются в разах;
- смотрите не только на время, но и на аллокации: счётчик alloc-rate в dotnet-counters или разность GC.GetAllocatedBytesForCurrentThread покажет цену в байтах;
- переписывайте в цикл код, который вызывается тысячи раз в секунду или обрабатывает миллионы элементов, например фильтр в цикле рендеринга с бюджетом 16 миллисекунд на кадр;
- оставляйте LINQ в редком коде, обработчиках кнопок и везде, где коллекция измеряется десятками элементов;
- устраняйте семантические ловушки раньше наносекунд: двойное перечисление и Count вместо Any стоят дороже любых итераторов;
- выбирайте структуру данных до выбора между запросом и циклом, HashSet и Dictionary дают больше любых переписываний;
- фиксируйте результат замера в комментарии рядом с оптимизацией, чтобы следующий разработчик не начинал спор заново.
Итог спокойный. LINQ на больших коллекциях в C# действительно платит измеримую цену: конвейер итераторов, упаковка перечислителей, замыкания и невозможность векторизации дают разрыв в три-пять раз на тривиальных фильтрах и лишние сотни байт мусора на вызов. Ручной цикл по спану с пулом буферов честно отрабатывает каждый наносекундный рубль. Разумная граница проходит не по вкусу, а по профилю: горячие пути с миллионами элементов и высокой частотой вызовов заслуживают ручной работы, остальное спокойно живёт на запросах. Измеряйте, читайте колонку Allocated, не забывайте про двойное перечисление, и вопрос превратится в инженерную процедуру на десять минут.