C# / .NET Производительность .NET: аллокации, Span, бенчмарки, GC и JIT
0%

Производительность .NET: аллокации, Span, бенчмарки, GC и JIT

Производительность .NET: от аллокаций до JIT

Сразу обозначим границу. Методология оптимизации — как измерять, как профилировать, как читать флеймграф, как строить нагрузочный тест, что кэшировать и как ускорять базу — живёт в треке производительности, и она общая для всех языков. Здесь мы говорим о другом: что именно в .NET является рычагом. Почему аллокация дороже, чем кажется; что такое Span<T> и когда он даёт эффект; чем Server GC отличается от Workstation; что JIT делает с вашим кодом на втором прогоне.

Правило, которое стоит принять до начала: сначала алгоритм и запросы к базе, потом аллокации, и только потом микрооптимизации. Экономия наносекунд в методе, который вызывается раз в минуту, — это не работа, а хобби.

Порядок действий: что оптимизировать первым

Верхний левый угол — это почти всегда база данных и сеть, а не C#. Если сервис отвечает 400 мс, из которых 380 мс — ожидание запроса без индекса, то Span<T> в парсере не изменит ничего. Проверяйте это первым: индексы и планы запросов, производительность БД.

Измерение: BenchmarkDotNet

Микробенчмарк на Stopwatch в цикле почти всегда врёт: JIT ещё не прогрет, код мог быть выброшен как мёртвый, а GC вмешался в середине замера. BenchmarkDotNet решает это за вас: прогрев, множественные запуски, статистика, отдельный процесс на конфигурацию.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]                 // покажет аллокации — часто важнее времени
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class ParsingBenchmarks
{
    private string _line = null!;

    [Params(10, 1_000)]           // прогоняем на разных размерах входа
    public int FieldCount;

    [GlobalSetup]
    public void Setup() => _line = string.Join(';', Enumerable.Range(0, FieldCount));

    [Benchmark(Baseline = true)]
    public int SplitAndParse()
    {
        var total = 0;
        foreach (var part in _line.Split(';'))    // аллокация массива + строки на каждое поле
            total += int.Parse(part);
        return total;
    }

    [Benchmark]
    public int SpanParse()
    {
        var total = 0;
        foreach (var range in _line.AsSpan().Split(';'))   // ноль аллокаций
            total += int.Parse(_line.AsSpan()[range]);
        return total;
    }
}

// В Program.cs: BenchmarkRunner.Run<ParsingBenchmarks>();

Как читать отчёт: колонка Mean — среднее время, Error/StdDev — разброс (если разброс сравним со средним, доверять числу нельзя), Ratio — отношение к базовой линии, Allocated — сколько байт мусора создаёт один вызов. Последняя колонка часто важнее времени: в сервисе под нагрузкой аллокации превращаются в паузы GC, которые видны в p99.

Типичные ошибки бенчмаркинга: запуск в Debug (BenchmarkDotNet предупредит и откажется), измерение метода, результат которого не используется (JIT выбросит вызов), общее состояние между итерациями, замер вместе с прогревом кэша. Подробнее о методике — «Бенчмаркинг».

Аллокации — главная валюта .NET

В управляемом рантайме сама аллокация дешёвая: сдвиг указателя в Gen 0. Дорогое — то, что происходит потом:

  • Сборка мусора. Чем больше вы аллоцируете, тем чаще Gen 0, тем больше объектов «выживает» и уезжает в Gen 1/Gen 2, а полная сборка Gen 2 — это уже десятки миллисекунд.
  • Промахи кэша. Свежие объекты разбросаны по куче; проход по массиву структур на порядок дружелюбнее к процессорному кэшу, чем по массиву ссылок (см. «Кэш и локальность»).
  • LOH. Объекты больше 85 000 байт попадают в кучу больших объектов, которая по умолчанию не уплотняется — отсюда фрагментация и рост RSS при формально свободной памяти.

Span и Memory

Span<T> — это пара «ссылка + длина»: окно в уже существующую память без копирования. Работает поверх массива, строки, стека и неуправляемой памяти одинаково.

Span как окно в буфер: ссылка и длина на стеке против копии массива в куче

// Разбор строки без единой аллокации
public static bool TryParseHeader(ReadOnlySpan<char> line, out ReadOnlySpan<char> name,
                                  out ReadOnlySpan<char> value)
{
    var colon = line.IndexOf(':');
    if (colon < 0) { name = default; value = default; return false; }

    name = line[..colon].Trim();          // слайс — не новая строка!
    value = line[(colon + 1)..].Trim();
    return true;
}

// Небольшой буфер на стеке вместо аллокации в куче
Span<byte> buffer = stackalloc byte[256];
var written = Encoding.UTF8.GetBytes(text, buffer);

Ограничения — прямое следствие устройства: Span<T> объявлен как ref struct, поэтому его нельзя положить в поле класса, захватить в лямбду или пронести через await. Для асинхронных сценариев есть Memory<T> — то же окно, но без стековых ограничений; его Span берут уже внутри синхронного участка.

Пулы вместо новых буферов

// ArrayPool: одалживаем массив и обязательно возвращаем
var pool = ArrayPool<byte>.Shared;
byte[] rented = pool.Rent(minimumLength: 64 * 1024);   // может вернуть БОЛЬШЕ, чем просили
try
{
    var read = await stream.ReadAsync(rented.AsMemory(0, 64 * 1024), ct);
    Process(rented.AsSpan(0, read));
}
finally
{
    // clearArray: true — если в буфере были чувствительные данные
    pool.Return(rented, clearArray: false);
}

Правила пулов: возвращать всегдаfinally), не использовать массив после возврата, помнить, что Rent возвращает буфер не меньше запрошенного — работать нужно со срезом. Для потоков есть RecyclableMemoryStream (Microsoft.IO), для объектов — ObjectPool<T> из Microsoft.Extensions.ObjectPool, для StringBuilderObjectPoolProvider.CreateStringBuilderPool().

Строки

Строки — источник большей части мусора в типичном бизнес-приложении:

// ❌ конкатенация в цикле: N промежуточных строк
var s = "";
foreach (var item in items) s += item + ";";

// ✅ StringBuilder — один растущий буфер
var sb = new StringBuilder();
foreach (var item in items) sb.Append(item).Append(';');

// ✅ Интерполяция в C# 10+ использует interpolated string handler:
//    строит результат в буфере, а не склеивает промежуточные строки
var message = $"Заказ {orderId} на сумму {total:C}";

// ✅ Логирование: шаблон не форматируется, если уровень выключен
logger.LogDebug("Обработан заказ {OrderId}", orderId);

// ✅ Поиск по набору символов с векторизацией (.NET 8)
private static readonly SearchValues<char> Separators = SearchValues.Create(";,\t");
var idx = line.AsSpan().IndexOfAny(Separators);

Отдельно: int.TryParse, DateTime.TryParse, Guid.TryParse и большинство форматтеров имеют перегрузки, принимающие ReadOnlySpan<char> и пишущие в Span<char> (TryFormat) — это позволяет разбирать и собирать данные вообще без промежуточных строк.

struct, ref и скрытый boxing

// readonly struct: компилятор не делает защитных копий при обращении к членам
public readonly struct Point(double x, double y)
{
    public double X { get; } = x;
    public double Y { get; } = y;
    public double Length => Math.Sqrt(X * X + Y * Y);
}

// in-параметр: передаём большую структуру по ссылке, но только для чтения
public static double Distance(in Matrix4x4 a, in Matrix4x4 b) => /* ... */ 0;

// ref struct: тип обязан жить на стеке (Span, ReadOnlySpan, Utf8JsonReader)
public ref struct LineEnumerator { /* ... */ }

Где boxing прячется, хотя вы его не писали:

  • присваивание структуры в переменную типа object или интерфейса;
  • params object[] — каждый переданный int упаковывается;
  • нестрого типизированные коллекции и IEnumerable над структурным перечислителем;
  • вызов Equals(object) вместо IEquatable<T>.Equals(T);
  • захват структуры лямбдой.

Найти это можно тем же [MemoryDiagnoser]: если метод, который «ничего не аллоцирует», показывает 24 байта на вызов — это почти наверняка boxing.

Обратная сторона: структура не всегда быстрее класса. Большая структура копируется целиком при каждой передаче. Практическое правило из «Основ» остаётся в силе: struct — для маленьких (до ~16–24 байт) неизменяемых значений.

LINQ на горячем пути

LINQ — превосходный инструмент, и переписывать его в циклы «ради скорости» без профилировщика не нужно. Но полезно понимать цену: каждый оператор — это объект-итератор плюс объект-делегат плюс, возможно, замыкание. В цикле, выполняемом миллион раз, это заметно.

// В горячем цикле: три аллокации на каждый вызов (Where, Select, замыкание)
var sum = orders.Where(o => o.Total > threshold).Select(o => o.Total).Sum();

// Ручной цикл: ноль аллокаций
decimal sum = 0;
foreach (var o in orders)
    if (o.Total > threshold) sum += o.Total;

// Доступ к внутреннему массиву List без копии (.NET 5+)
foreach (ref var item in CollectionsMarshal.AsSpan(list))
    item.Counter++;    // работаем со структурами по ссылке, без копирования

Ориентир: в обработчике HTTP-запроса, который выполняется тысячу раз в секунду, LINQ не имеет значения на фоне похода в базу. В цикле, обрабатывающем миллионы элементов, — имеет. Решает профилировщик, а не вкус.

Генераторы кода вместо рефлексии

Рефлексия удобна и дорога: разбор метаданных, кэш, невозможность обрезать код при публикации. Source generators переносят работу на этап компиляции.

// 1. JSON без рефлексии (см. статью про ввод-вывод)
[JsonSerializable(typeof(OrderDto))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

// 2. Логирование без боксинга и без форматирования при выключенном уровне
public static partial class Log
{
    [LoggerMessage(Level = LogLevel.Information,
        Message = "Заказ {OrderId} обработан за {Elapsed} мс")]
    public static partial void OrderProcessed(ILogger logger, int orderId, double elapsed);
}

// 3. Регулярное выражение компилируется в код на этапе сборки
public static partial class Patterns
{
    [GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$", RegexOptions.CultureInvariant)]
    public static partial Regex IsoDate();
}

Выигрыш двойной: меньше аллокаций и быстрее холодный старт — и, что не менее важно, такой код совместим с trimming и Native AOT, потому что не строит типы в рантайме.

GC: настройка под нагрузку

// В .csproj — самая частая и самая недооценённая настройка серверного приложения
// <ServerGarbageCollection>true</ServerGarbageCollection>
// <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
  • Workstation GC — один поток сборки, оптимизирован под низкую задержку одного приложения. По умолчанию для консольных и десктопных приложений.
  • Server GC — по куче и потоку сборки на ядро, кратно выше пропускная способность. Включён по умолчанию в ASP.NET Core, но не в консольных воркерах — если ваш фоновый сервис обрабатывает поток сообщений, включите явно.
  • Concurrent (background) GC — фоновая сборка Gen 2 без полной остановки приложения.

В контейнерах критично сказать рантайму о лимитах. .NET читает cgroup-лимиты автоматически, но полезно задать явно:

# Жёсткий потолок управляемой кучи — 75% лимита пода
DOTNET_GCHeapHardLimitPercent=4B      # шестнадцатеричное: 0x4B = 75
DOTNET_gcServer=1

Симптом неверной настройки — под с лимитом 512 МБ убивается OOM-killer’ом, хотя приложению «хватает»: Server GC на 16-ядерной ноде создаёт 16 куч и позволяет им расти. В .NET 9 появился DATAS (динамическая адаптация к размеру приложения), который автоматически сжимает число куч под реальную нагрузку — для контейнеров это заметное улучшение.

Что не надо делать: звать GC.Collect() в проде. Почти всегда вы сделаете хуже адаптивного сборщика. Исключение — редкие точки «только что освободили гигабайт и следующий час будем простаивать». Устройство трассирующих сборщиков разобрано в «Памяти».

JIT: что происходит с кодом на самом деле

Практические следствия:

  • Первые вызовы метода медленнее. Это нормально и это причина, по которой бенчмарк обязан прогреваться. В проде многоуровневая компиляция даёт быстрый старт и оптимальный установившийся режим.
  • Dynamic PGO включён по умолчанию с .NET 8 и даёт «бесплатные» проценты: рантайм собирает статистику типов и ветвлений в Tier 0 и использует её при оптимизации. Именно поэтому простой переход на новую версию .NET часто ускоряет сервис без единой правки кода.
  • Инлайнинг возможен только для небольших методов; гигантский метод не будет встроен никогда. Маленькие методы — это не только читаемость.
  • ReadyToRun ускоряет холодный старт, оставляя JIT для горячих путей; Native AOT убирает JIT совсем, но лишает динамических возможностей (см. «Деплой»).

Почему обновление версии — это оптимизация

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

Профилирование работающего сервиса

Порядок разбора «сервис тормозит» в .NET:

# 1. Живые счётчики: CPU, память, GC, очередь пула потоков
dotnet-counters monitor -p <PID> --counters System.Runtime,Microsoft.AspNetCore.Hosting

# 2. Трасса на 30 секунд для анализа в PerfView / Speedscope
dotnet-trace collect -p <PID> --duration 00:00:30 --profile cpu-sampling

# 3. Снимок кучи, если растёт память
dotnet-gcdump collect -p <PID>

Что смотреть и какие выводы делать:

Наблюдение Наиболее вероятная причина
ThreadPool Queue Length растёт блокирующий код (sync-over-async), голодание пула
Gen 0 очень часто много короткоживущего мусора: строки, LINQ, boxing
Gen 2 / LOH часто большие буферы, кэш без ограничения, утечка
% Time in GC больше 10–15% давление на кучу, стоит заняться аллокациями
CPU низкий, латентность высокая ждём внешнюю зависимость: база, HTTP, блокировка
Растёт RSS при стабильном Gen 2 фрагментация LOH или неуправляемая память

Дальнейшая методика — профилирование CPU, рабочий процесс оптимизации и нагрузочное тестирование.

Чеклист типового ASP.NET Core сервиса

  • Включён Server GC (для воркеров — явно), лимиты памяти согласованы с контейнером.
  • HttpClient через фабрику, соединения переиспользуются (см. статью про HTTP).
  • Запросы к БД: AsNoTracking для чтения, проекции в DTO, нет N+1, есть пагинация.
  • Ответы больше нескольких мегабайт отдаются потоком, а не через промежуточную строку.
  • JsonSerializerOptions переиспользуется; для AOT — контекст-генератор.
  • Нет .Result/.Wait() — пул потоков не голодает.
  • Кэш там, где данные читают чаще, чем меняют (см. кэширование).
  • Есть нагрузочный тест и зафиксированный базовый уровень p95/p99 — иначе «стало быстрее» это мнение, а не факт.

Типичные ошибки

  1. Оптимизация без измерения — недели работы ради 1% при 300 мс в базе рядом.
  2. Stopwatch вместо BenchmarkDotNet — измерили прогрев JIT и шум планировщика.
  3. Span там, где вызов раз в секунду — сложность без выгоды.
  4. Забытый Return в ArrayPool — пул превращается в утечку.
  5. Использование массива после возврата в пул — повреждение данных, воспроизводится раз в неделю.
  6. Большие структуры — копирование дороже, чем аллокация класса.
  7. Server GC в контейнере без лимитов — OOM-kill при формально свободной памяти.
  8. GC.Collect() «чтобы освободить память» — паузы и деградация.
  9. Кэш без ограничения размера и TTL — Gen 2 растёт, память течёт.
  10. Отказ от новой версии .NET — самый дешёвый источник производительности, который просто не забрали.

Итог

  • Порядок: алгоритм и запросы к данным → аллокации → микрооптимизации. Не наоборот.
  • BenchmarkDotNet с MemoryDiagnoser — единственный корректный способ мерить код на .NET.
  • Span/Memory, stackalloc, ArrayPool убирают копирования и давление на GC.
  • Boxing прячется в интерфейсах, object-параметрах и лямбдах; ищите его по аллокациям.
  • Генераторы кода дают скорость старта, экономию памяти и совместимость с AOT.
  • Server GC, лимиты кучи в контейнере и свежая версия рантайма — самые дешёвые рычаги.
  • JIT оптимизирует по факту исполнения: Tier 0, OSR, dynamic PGO работают на вас бесплатно.

Источники: ежегодные обзоры Stephen Toub «Performance Improvements in .NET» (devblogs.microsoft.com/dotnet), документация BenchmarkDotNet, Konrad Kokosa «Pro .NET Memory Management» и его блог tooslowexception.com, руководство по настройке GC.

Что дальше

На этом углублённая часть курса закончена. Вернитесь к обзору курса, чтобы перечитать разделы, к которым захотелось углубиться, пройдите трек производительности за общей методикой оптимизации — или откройте learn.microsoft.com/dotnet и примените всё это к собственному сервису.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов