Производительность .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> — это пара «ссылка + длина»: окно в уже существующую память без копирования.
Работает поверх массива, строки, стека и неуправляемой памяти одинаково.
// Разбор строки без единой аллокации
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, для StringBuilder —
ObjectPoolProvider.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: что происходит с кодом на самом деле
быстрая компиляция,
минимум оптимизаций"] T0 --> HOT{"Метод вызван
30+ раз?"} HOT -->|"нет"| STAY["Остаётся в Tier 0
старт приложения быстрее"] HOT -->|"да"| T1["Tier 1
полная оптимизация"] T0 --> LOOP{"Долгий цикл
внутри метода?"} LOOP -->|"да"| OSR["On-Stack Replacement:
переключение прямо в цикле"] OSR --> T1 T1 --> PGO["Dynamic PGO:
счётчики из Tier 0 подсказывают,
какие ветки горячие"] PGO --> DEV["Девиртуализация,
агрессивный инлайнинг,
векторизация"] DEV --> NATIVE["Оптимальный машинный код"] R2R["ReadyToRun:
предкомпиляция при публикации"] -.->|"убирает Tier 0
для холодного старта"| T0 AOT["Native AOT:
JIT отсутствует вовсе"] -.->|"мгновенный старт,
ограничения на рефлексию"| NATIVE
Практические следствия:
- Первые вызовы метода медленнее. Это нормально и это причина, по которой бенчмарк обязан прогреваться. В проде многоуровневая компиляция даёт быстрый старт и оптимальный установившийся режим.
- 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% при 300 мс в базе рядом.
Stopwatchвместо BenchmarkDotNet — измерили прогрев JIT и шум планировщика.Spanтам, где вызов раз в секунду — сложность без выгоды.- Забытый
ReturnвArrayPool— пул превращается в утечку. - Использование массива после возврата в пул — повреждение данных, воспроизводится раз в неделю.
- Большие структуры — копирование дороже, чем аллокация класса.
- Server GC в контейнере без лимитов — OOM-kill при формально свободной памяти.
GC.Collect()«чтобы освободить память» — паузы и деградация.- Кэш без ограничения размера и TTL — Gen 2 растёт, память течёт.
- Отказ от новой версии .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 и примените всё это к собственному сервису.