.NET Tutorial
Middle7 мин чтенияВопрос 2 из 10

Что такое GC паузы и как минимизировать аллокации?

Как аллокации превращаются в паузы

Высокая скорость аллокаций
(500 МБ/с)

Бюджет Gen 0 быстро
исчерпывается

Частые Gen 0/1 GC

Объекты доживают до Gen 2
(кэши, долгие запросы)

Gen 2 GC и LOH:
длинные паузы

CPU на сборку,
паузы потоков

Хвостовые задержки p99 растут

Высокая скорость аллокаций
(500 МБ/с)

Бюджет Gen 0 быстро
исчерпывается

Частые Gen 0/1 GC

Объекты доживают до Gen 2
(кэши, долгие запросы)

Gen 2 GC и LOH:
длинные паузы

CPU на сборку,
паузы потоков

Хвостовые задержки p99 растут

Аллокации → сборки → паузы → задержка ответа
Как увидеть
dotnet-counters monitor -p <pid> --counters System.Runtime
# % Time in GC, Allocation Rate, Gen 0/1/2 GC Count, GC Pause Time (.NET 8+ счётчик)

Метрики OpenTelemetry (dotnet.gc.pause.time, dotnet.gc.collections) в Grafana позволяют связать всплески p99 с Gen 2 сборками.

Главные источники аллокаций и замены

Строки

C#
// Плохо: новая строка на каждой итерации
string csv = "";
foreach (var item in items) csv += item.Id + ",";

// Хорошо
var sb = new StringBuilder(items.Count * 8);
foreach (var item in items) sb.Append(item.Id).Append(',');
var csv2 = string.Join(',', items.Select(i => i.Id));     // тоже нормально

// Разбор без Substring
ReadOnlySpan<char> line = "2025-09-30;42;paid";
int sep = line.IndexOf(';');
var date = DateOnly.Parse(line[..sep]);                    // без промежуточной строки

// Сравнение без ToLower() (аллокация новой строки)
bool eq = string.Equals(a, b, StringComparison.OrdinalIgnoreCase);

// Интерполяция в C# 10+ не боксит и использует пул буферов
logger.LogInformation("Order {Id}", id);                  // лучше — шаблоны логов, а не интерполяция

LINQ и замыкания в горячих путях

C#
// Каждый вызов: итераторы Where/Select + замыкание (захват threshold) + List
public List<int> Hot(List<Order> orders, decimal threshold) =>
    orders.Where(o => o.Total > threshold).Select(o => o.Id).ToList();

// Горячий путь (миллионы вызовов): цикл с заранее заданной ёмкостью
public List<int> Hot2(List<Order> orders, decimal threshold)
{
    var result = new List<int>(orders.Count);
    foreach (var o in orders)                     // foreach по List<T> — struct-энумератор, без аллокации
        if (o.Total > threshold) result.Add(o.Id);
    return result;
}

LINQ в обычном коде — нормально и читаемо; оптимизировать стоит только измеренные горячие места. (В .NET 8–10 многие LINQ-операции существенно оптимизированы.)

Boxing

C#
object o = 42;                                          // boxing
var list = new ArrayList { 1, 2, 3 };                   // boxing каждого
Dictionary<MyStruct, int> dict;                         // struct без IEquatable<T> → Equals(object) → boxing
string.Format("{0}", 42);                                // params object[] + boxing

// Решения: generic-коллекции, IEquatable<T> на struct, generic-методы с ограничениями
public readonly record struct Key(int A, int B);        // record struct реализует IEquatable<T>

Большие буферы → LOH

C#
// Плохо: 1 МБ на каждый запрос → LOH → Gen 2 сборки
var buffer = new byte[1024 * 1024];

// Хорошо
var rented = ArrayPool<byte>.Shared.Rent(1024 * 1024);
try { /* ... */ } finally { ArrayPool<byte>.Shared.Return(rented); }

// MemoryStream → RecyclableMemoryStream
private static readonly RecyclableMemoryStreamManager Streams = new();
await using var ms = Streams.GetStream();

Кэширование неизменяемого

C#
// Плохо: новые объекты на каждый вызов
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };   // ещё и сбрасывает кэш метаданных!
var regex = new Regex(@"^\d+$");

// Хорошо
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
[GeneratedRegex(@"^\d+$")] private static partial Regex Digits();
private static readonly FrozenSet<string> AllowedCurrencies = new[] { "UZS", "USD" }.ToFrozenSet();

Пулинг объектов

C#
builder.Services.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<ObjectPoolProvider>().CreateStringBuilderPool());

public string Render(ObjectPool<StringBuilder> pool, Order o)
{
    var sb = pool.Get();
    try { return sb.Append("Order ").Append(o.Id).ToString(); }
    finally { pool.Return(sb); }
}

Сводная таблица

Источник Замена
+ в цикле для строк StringBuilder, string.Join, string.Create
Substring, Split для разбора Span, MemoryExtensions.Split (.NET 8), IndexOf
ToLower() для сравнения StringComparison.OrdinalIgnoreCase
LINQ + лямбды с захватом в горячем цикле foreach, static-лямбды, предвычисление
new byte[] больших размеров ArrayPool, RecyclableMemoryStream, Pipelines
Boxing value types Generics, IEquatable<T>, record struct
new JsonSerializerOptions() / new Regex() на вызов static readonly, source generators
Логи с интерполяцией [LoggerMessage], шаблоны
Task<T> для часто синхронных результатов ValueTask<T>
Коллекции без ёмкости new List<T>(capacity), EnsureCapacity
Лишние ToList()/ToArray() Стриминг, IEnumerable, CollectionsMarshal.AsSpan

Настройки GC

XML
<PropertyGroup>
  <ServerGarbageCollector>true</ServerGarbageCollector>          <!-- по умолчанию для ASP.NET Core -->
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> <!-- фоновая Gen 2 -->
  <GarbageCollectionAdaptationMode>1</GarbageCollectionAdaptationMode>  <!-- DATAS: подстройка числа куч (по умолчанию в .NET 9) -->
</PropertyGroup>
Временный режим низкой задержки для критичной секции
var old = GCSettings.LatencyMode;
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;   // избегать блокирующих Gen 2, пока возможно
try { RunLatencySensitiveWork(); }
finally { GCSettings.LatencyMode = old; }

Также GC.TryStartNoGCRegion(size) — выполнить участок без сборок (если аллокации укладываются в бюджет) — для очень специфичных сценариев.

Как подходить к оптимизации

нет

да

p99 / CPU проблемы

dotnet-counters:
% time in GC, allocation rate

GC — значимая часть?

Искать другое:
I/O, блокировки, алгоритмы

dotnet-trace gc-verbose / PerfView:
кто аллоцирует больше всего

Оптимизировать топ-3 источника

BenchmarkDotNet [MemoryDiagnoser]
+ повторное измерение под нагрузкой

нет

да

p99 / CPU проблемы

dotnet-counters:
% time in GC, allocation rate

GC — значимая часть?

Искать другое:
I/O, блокировки, алгоритмы

dotnet-trace gc-verbose / PerfView:
кто аллоцирует больше всего

Оптимизировать топ-3 источника

BenchmarkDotNet [MemoryDiagnoser]
+ повторное измерение под нагрузкой

Алгоритм работы с аллокациями

Вопросы на засыпку

Почему короткоживущие объекты «дешёвые», а долгоживущие — «дорогие»?

Аллокация в .NET — сдвиг указателя, очень быстро. Сборка Gen 0 обходит только живые объекты; если почти всё умерло — сборка тривиальна. Дорого, когда объекты выживают: их копируют при уплотнении, продвигают в старшие поколения, и в итоге нужны дорогие Gen 2 сборки. Особенно вредны объекты «среднего» времени жизни (кэши с TTL в минуты, долгие запросы).

Создаёт ли async-метод аллокации?

Если метод завершился синхронно (без реальной приостановки), state machine остаётся на стеке — аллокаций нет (кроме Task<T> результата; для частых значений кэшируется, либо используйте ValueTask). При первой реальной приостановке state machine упаковывается в кучу.

Помогает ли GC.Collect() уменьшить паузы?

Нет — наоборот: он запускает полную блокирующую сборку в произвольный момент и продвигает живые объекты в старшие поколения. Исключения — очень специфичные сценарии (после загрузки огромного разового набора данных, в тестах).

Что такое DATAS?

Dynamic Adaptation To Application Sizes (.NET 8+, по умолчанию в .NET 9 для Server GC): GC динамически меняет число куч и бюджеты в зависимости от нагрузки. В контейнерах с небольшими лимитами памяти снижает потребление, сохраняя пропускную способность Server GC.

Итог

GC-паузы — прямое следствие скорости аллокаций и числа выживающих объектов. Измеряйте % time in GC и allocation rate, находите главные источники аллокаций профилировщиком и заменяйте их: Span, пулы, кэширование неизменяемого, отсутствие boxing и лишних коллекций. Настройки GC — вторичный рычаг после уменьшения аллокаций в горячих путях.