Перейти до вмісту

Збирання сміття й ресурси

Як .NET керує пам’яттю й ресурсами: як GC знаходить сміття, навіщо йому покоління, чим Dispose відрізняється від фіналізатора, звідки беруться витоки і який режим GC обрати. На співбесіді тема перевіряє, чи розумієте ви ціну алокацій і час життя об’єктів.

Перед цією темою: Типи й пам’ять.

Junior+

Як працює збирач сміття в .NET?

Section titled “Як працює збирач сміття в .NET?”
Відповідь

Що це

Збирач сміття (GC) — частина .NET, яка сама звільняє пам’ять об’єктів. Викликати free або delete, як у C++, не потрібно.

Навіщо

Ручне звільнення пам’яті призводить до витоків і до звернення до вже звільненої пам’яті. GC прибирає обидві помилки.

Як він вирішує, що видаляти

  1. Корені — локальні змінні методів, що виконуються, і статичні поля.
  2. GC іде за посиланнями від коренів і позначає досяжні об’єкти.
  3. Непозначені об’єкти — сміття, навіть якщо посилаються один на одного по колу.

GC запускається сам, зазвичай під час чергового new, коли закінчилося місце для нових об’єктів.

Типова помилка

Викликати GC.Collect() «для порядку». Ручний виклик зазвичай лише сповільнює програму.

Як влаштовано

Купа поділена на покоління за віком об’єктів: у поколінні 0 нові, у поколінні 2 довгоживучі. Зазвичай збирається лише покоління 0.

  1. Алокація. Новий об’єкт виділяється зсувом покажчика в поколінні 0 — майже так само дешево, як на стеку. Пам’ять видається вже обнуленою.
  2. Зупинка. Перед блокувальним збиранням рантайм зупиняє всі керовані потоки, крім того, що його запустив.
  3. Маркування. GC обходить граф від коренів: локальних змінних і регістрів потоків, статичних полів, GC-хендлів, об’єктів, що чекають на фіналізацію.
  4. Звільнення. Мертві ділянки або прибираються ущільненням, або заносяться до списку вільних блоків (sweep).
  5. Продовження. Потоки відновлюють роботу.

Коли що обирати

Після маркування GC сам обирає спосіб звільнення:

Ущільнення Sweep
Що робить зсуває живі об’єкти, оновлює посилання вносить мертві ділянки до списку вільних
Плюс немає фрагментації швидше
Мінус копіювання дорожче у купі лишаються діри

Пастки

  • Збирання запускає вичерпання бюджету покоління 0 або купи великих об’єктів, сигнал ОС про брак пам’яті чи GC.Collect. Пороги рантайм підлаштовує на ходу.
  • GC.Collect() у коді застосунку шкодить: живі об’єкти просуваються в старші покоління, а самоналаштування збивається.

Правило

Не керуйте GC вручну: зменшуйте кількість алокацій і час життя об’єктів.

Під капотом

  • Точний збирач. Для кожного методу JIT зберігає GC info — де в кожній точці коду лежать посилання.
  • Рання смерть. В оптимізованому коді змінна перестає бути коренем після останнього використання. Тому під час передачі сирого IntPtr у нативний код потрібен GC.KeepAlive.
  • Debug і Release. У Debug JIT подовжує життя локальних змінних до кінця методу, і поведінка під час налагодження відрізняється від продакшену.
  • Safe points. Блокувальне збирання чекає, доки потоки дійдуть до безпечної точки. Потік у нативному коді не зупиняється, але повернутися в керований код до кінця збирання не зможе.
  • Card table. Щоб зібрати молоді об’єкти, не обходячи старі, write barrier під час запису посилання в поле позначає ділянку пам’яті. Позначені ділянки стають додатковими коренями.
  • Регіони. Із .NET 7 купа на 64-бітних Windows і Linux складається з регіонів: за замовчуванням 4 МБ, для великих і закріплених об’єктів — 32 МБ.

Ціна

  • Маркування пропорційне кількості живих об’єктів і посилань, ущільнення — обсягу тих, що вижили.
  • Мертві об’єкти майже безкоштовні: їх не обходять і не копіюють.

Компроміси

Бюджет покоління 0 балансує частоту збирань і пам’ять. Більший бюджет — рідші збирання й вища пропускна здатність, але більше пам’яті. GC підбирає бюджети сам, а режими GC зсувають цей баланс.

Як перевірити в продакшені

  • dotnet-counters (назви .NET 9+): dotnet.gc.pause.time і dotnet.gc.collections за поколіннями.
  • PerfView (GCStats): тривалість і причина кожного збирання.

Підсумок: платимо за живі об’єкти й частоту алокацій, а не за сміття.

Приклад
var cache = new List<byte[]> { new byte[1024] };
var reachable = new WeakReference(cache[0]);
var garbage = MakeGarbage();
GC.Collect(); // лише для демонстрації, у коді застосунку не потрібен
Console.WriteLine(reachable.IsAlive); // True: досяжний з локальної змінної cache
Console.WriteLine(garbage.IsAlive); // False: на масив не лишилося сильних посилань
GC.KeepAlive(cache); // cache вважається живою до цього рядка
static WeakReference MakeGarbage()
{
var data = new byte[1024]; // посилання лише в локальній змінній
return new WeakReference(data); // слабке посилання не корінь
}
Junior+

Навіщо потрібні IDisposable і using?

Section titled “Навіщо потрібні IDisposable і using?”
Відповідь

Що це

IDisposable — інтерфейс з одним методом Dispose(). Його реалізують класи, які тримають ресурси ОС: відкритий файл, сокет, з’єднання з БД.

Навіщо

GC звільняє лише пам’ять і робить це коли захоче. Файл чи з’єднання треба закрити одразу, інакше їх не зможе отримати ніхто інший.

Як користуватися

  • using (var reader = new StreamReader(path)) { ... } — Dispose наприкінці блоку.
  • using var reader = new StreamReader(path); — using-оголошення (C# 8): Dispose наприкінці методу або блоку.
  • await using var stream = ...; — для типів з IAsyncDisposable, у яких закриття асинхронне.

Типова помилка

Забути using. Файл лишається заблокованим, а з’єднання не повертається до пулу, доки GC колись не дістанеться до об’єкта.

Як влаштовано

  1. Вираз у using обчислюється й зберігається в змінну лише для читання.
  2. Тіло виконується всередині try.
  3. У finally викликається Dispose, якщо змінна не null.

Кілька об’єктів в одному using і using-оголошення поспіль звільняються у зворотному порядку.

Коли що обирати

Форма Коли
using (...) { } ресурс потрібен лише в частині методу
using var x = ...; ресурс потрібен до кінця методу або блоку
await using закриття виконує I/O: Stream, DbConnection

Пастки

  • Тип лише з IAsyncDisposable не можна звільнити звичайним using. Тому добра практика — реалізувати обидва інтерфейси.
  • HttpClient не створюють і не звільняють на кожен запит: з’єднання закриваються не одразу, і під навантаженням закінчуються порти. Його перевикористовують або беруть з IHttpClientFactory.
  • Залежності з DI вручну не звільняють — це робить контейнер наприкінці scope.

Правило

Звільняє власник — той, хто створив об’єкт або отримав володіння. Клас, який володіє полем з IDisposable, сам реалізує IDisposable. Dispose ідемпотентний, а решта методів після нього кидають ObjectDisposedException — зручно через ObjectDisposedException.ThrowIf (.NET 7+).

Під капотом

  • Dispose звільняє ресурс, але не пам’ять: об’єкт живе, доки на нього є посилання.
  • Якщо Dispose не викликали, некерований ресурс звільнить лише фіналізатор — метод, який GC викликає перед знищенням об’єкта. Це станеться колись, а в .NET 5+ під час завершення процесу фіналізатори не викликаються.
  • Тому дескриптор загортають у SafeHandle — готову обгортку з власним фіналізатором. Dispose викликає GC.SuppressFinalize, щоб скасувати вже непотрібну фіналізацію.

Ціна

  • Незвільнені ресурси видно не за купою, а за дескрипторами, пулом з’єднань і нативною пам’яттю.
  • Transient-сервіс з IDisposable, отриманий з кореневого DI-провайдера, контейнер тримає до власного Dispose. Це витік.

Компроміси

  • Порядок. StreamWriter звільняють раніше за Stream під ним, інакше буфер не скинеться. Using-оголошення якраз звільняють у зворотному порядку.
  • Володіння в API. Якщо метод приймає Stream, домовтеся, хто його закриває. Для цього в StreamReader і StreamWriter є параметр leaveOpen.
  • Готові екземпляри. DI не звільняє об’єкти, зареєстровані готовими: AddSingleton(new X()).

Як перевірити в продакшені

  • Аналізатори: CA2000 — об’єкт не звільнено до втрати посилання, CA2213 — не звільнено поле-власник, CA1816 — неправильний виклик GC.SuppressFinalize.
  • Метрики: кількість відкритих дескрипторів процесу, зайнятість пулу з’єднань, ріст working set за стабільної купи.

Підсумок: Dispose — про володіння ресурсом, і його витоки видно за дескрипторами й пулами, а не за купою.

Приклад
// using-оголошення (C# 8): Dispose наприкінці методу, навіть у разі винятку
static string ReadFirstLine(string path)
{
using var reader = new StreamReader(path);
return reader.ReadLine() ?? "";
}
// await using: асинхронне очищення через DisposeAsync
static async Task WriteAsync(string path, string text)
{
await using var stream = new FileStream(path, FileMode.Create);
await using var writer = new StreamWriter(stream);
await writer.WriteAsync(text);
} // спершу writer (скидає буфер), потім stream
// На що компілятор перетворює using (var r = new StreamReader("a.txt")) { ... }
var r = new StreamReader("a.txt");
try
{
Console.WriteLine(r.ReadLine());
}
finally
{
r?.Dispose();
}
Middle+

Навіщо GC покоління і що таке LOH та POH?

Section titled “Навіщо GC покоління і що таке LOH та POH?”
Відповідь

Що це

Купа поділена на три покоління за віком об’єктів:

  • Покоління 0 — нові об’єкти, збирається найчастіше.
  • Покоління 1 — ті, що пережили одне збирання.
  • Покоління 2 — довгоживучі: кеші, синглтони, статичні дані.

Об’єкт, що пережив збирання, переходить до наступного покоління. Дізнатися покоління можна через GC.GetGeneration(obj).

Навіщо

Більшість об’єктів живе недовго. Зібрати маленьке покоління 0 швидше, ніж усю купу.

Окремі купи

Купа Що в ній
LOH (large object heap) об’єкти від 85 000 байт; збирається лише під час повного збирання
POH (pinned object heap, .NET 5+) масиви, які не можна рухати, наприклад буфери для нативного коду

Типова помилка

Створювати великий тимчасовий масив на кожен запит: кожен потрапляє до LOH і наближає дороге повне збирання.

Як влаштовано

  • Збирання покоління N збирає й усі молодші. Збирання покоління 2 — повне.
  • Кожне покоління має бюджет алокацій; коли його вичерпано, запускається збирання. Якщо виживає багато об’єктів, GC збільшує бюджет.
  • LOH логічно є частиною покоління 2. Поріг 85 000 байт можна підняти через GCLOHThreshold.
  • LOH зазвичай не ущільнюється, а підмітається до списку вільних блоків, тому фрагментується.
  • POH теж збирається з поколінням 2, і об’єкти в ній ніколи не рухаються. Масив створюють через GC.AllocateArray<T>(length, pinned: true) або GC.AllocateUninitializedArray<T>(length, pinned: true).

Коли що обирати

Завдання Рішення
Закріпити об’єкт на час одного виклику fixed: об’єкт лишається в поколінні 0
Довгоживучий буфер для I/O та interop масив у POH
Тимчасовий великий буфер ArrayPool<T>.Shared замість new

Пастки

  • Обнулення пам’яті під великий масив саме по собі дороге.
  • Фрагментацію LOH прибирає разове ущільнення: GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce. За жорсткого ліміту купи, наприклад у контейнері з лімітом пам’яті, LOH ущільнюється автоматично.
  • Об’єкти, які живуть «трохи довше за запит», доживають до покоління 2 і роблять повні збирання частими.

Правило

Не виділяйте тимчасові великі масиви в гарячому коді й не тримайте об’єкти довше, ніж потрібно.

Під капотом

  • Посилання з покоління 2 на покоління 0 — теж корінь для збирання молодих поколінь. Під час запису посилання в поле чи елемент масиву JIT вставляє write barrier, який позначає ділянку пам’яті в card table.
  • Під час збирання поколінь 0 і 1 (ефемерних) GC переглядає лише позначені ділянки старших поколінь.
  • Із .NET 7 покоління складаються з регіонів: за замовчуванням 4 МБ для малих об’єктів і 32 МБ для LOH і POH.
  • Закріплений об’єкт в ефемерному поколінні не дає ущільнити пам’ять навколо себе. POH виносить довгоживучі закріплені буфери окремо.

Ціна

  • Write barrier спрацьовує на кожен запис посилання в поле. Якщо старі об’єкти масово посилаються на нові, як великий кеш зі свіжими записами, ефемерне збирання переглядає багато ділянок.
  • Об’єкти середнього віку, що помирають у поколінні 2, — головне джерело дорогих повних збирань.
  • LOH має власний бюджет: тимчасові великі масиви запускають повні збирання.

Компроміси

  • Пули (ArrayPool<T>) прибирають алокації, але буфери в них живуть у поколінні 2, а повернення до пулу потребує дисципліни.
  • POH прибирає фрагментацію від pinning, але об’єкт у ній живе до повного збирання. Для коротких буферів fixed у поколінні 0 кращий.

Як перевірити в продакшені

  • dotnet-counters (назви .NET 9+): dotnet.gc.collections за поколіннями, dotnet.gc.last_collection.heap.size і dotnet.gc.last_collection.heap.fragmentation.size з розбивкою gen0, gen1, gen2, loh, poh.
  • PerfView (GCStats): причина AllocLarge означає, що повні збирання спричиняє LOH.
  • З коду — GC.GetGCMemoryInfo().

Підсумок: дорого те, що доживає до покоління 2 і помирає там, зокрема тимчасові великі масиви.

Приклад
using System.Runtime;
var obj = new object();
Console.WriteLine(GC.GetGeneration(obj)); // 0: новий об'єкт
GC.Collect(0); // зібрати лише покоління 0
Console.WriteLine(GC.GetGeneration(obj)); // 1: пережив збирання й просунувся
GC.Collect(); // повне збирання
Console.WriteLine(GC.GetGeneration(obj)); // 2: далі просуватися нікуди
var large = new byte[85_000]; // від 85 000 байт — одразу в LOH
var buffer = GC.AllocateArray<byte>(4096, pinned: true); // POH, .NET 5+
// Разово ущільнити LOH під час наступного блокувального повного збирання
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
Console.WriteLine($"gen0: {GC.CollectionCount(0)}, gen2: {GC.CollectionCount(2)}"); // зазвичай gen0: 3, gen2: 2
GC.KeepAlive(large);
GC.KeepAlive(buffer);
Middle+

Що таке фіналізатор, Dispose pattern і до чого тут SafeHandle?

Section titled “Що таке фіналізатор, Dispose pattern і до чого тут SafeHandle?”
Відповідь

Що це

Фіналізатор оголошується як ~ClassName(), і компілятор перетворює його на перевизначення Object.Finalize. Викликає його GC, а не ви, і в непередбачуваний момент.

Навіщо

Це страховка. Якщо клас безпосередньо тримає некерований ресурс, наприклад покажчик на нативну пам’ять, а Dispose забули, ресурс усе ж звільниться.

Як користуватися

  • Dispose pattern — стандартна схема: Dispose() і фіналізатор викликають спільний метод Dispose(bool disposing).
  • SafeHandle — готова обгортка над дескриптором ОС з власним фіналізатором (SafeFileHandle та інші). З нею власний фіналізатор не потрібен.

Типова помилка

Порожній фіналізатор «про всяк випадок». Він лише сповільнює програму. У структур фіналізатора бути не може.

Як влаштовано

  1. Під час створення об’єкт з фіналізатором реєструється в черзі фіналізації.
  2. Коли GC знаходить об’єкт недосяжним, запис переноситься до черги f-reachable. Об’єкт і все, на що він посилається, знову тимчасово досяжні.
  3. Потік фіналізації викликає Finalize.
  4. Наступне збирання цього покоління звільняє пам’ять.

Виходить щонайменше два збирання й просування в старше покоління.

Коли що обирати

Клас володіє Що реалізувати
лише керованими IDisposable, клас sealed простий Dispose()
керованими ресурсами, клас відкритий для успадкування Dispose() і protected virtual Dispose(bool)
сирим IntPtr загорнути його в нащадка SafeHandle

Пастки

  • У Dispose(false), тобто під час виклику з фіналізатора, не можна чіпати інші керовані об’єкти: вони могли бути вже фіналізовані.
  • Нащадок не реалізує IDisposable заново, а перевизначає Dispose(bool) і викликає base.Dispose(disposing).
  • Dispose(bool) захищають прапорцем від повторного виклику.

Правило

Dispose() викликає Dispose(true) і GC.SuppressFinalize(this), а фіналізатор, якщо він є, — Dispose(false). SafeHandle гарантує, що ReleaseHandle виконається рівно один раз.

Під капотом

  • Фіналізований об’єкт під час створення реєструється в черзі, тому його алокація дорожча.
  • У CoreCLR фіналізатори виконує один потік. Виняток у фіналізаторі завершує процес.
  • SafeHandle успадковує CriticalFinalizerObject: його фіналізатор виконується після звичайних того ж збирання. Тому звичайний фіналізатор, наприклад той, що скидає буфер, ще може користуватися дескриптором.
  • P/Invoke збільшує лічильник посилань SafeHandle на час виклику. Дескриптор не закриється посеред виклику й не буде перевикористаний ОС для іншого ресурсу (recycle attack).

Ціна

  • Об’єкт і весь його граф переживають зайве збирання й ідуть у старше покоління. Тисячі таких об’єктів на секунду навантажують покоління 1 і 2.
  • Фіналізатор, заблокований на lock, мережі чи синхронному I/O, зупиняє чергу, і пам’ять перестає звільнятися.

Компроміси

  • Порядок фіналізації не визначений, а в .NET 5+ фіналізатори не викликаються під час виходу з процесу. Гарантоване очищення роблять через Dispose, IHostApplicationLifetime або AppDomain.ProcessExit.
  • Клас із сирим IntPtr без SafeHandle ризикує ранньою фіналізацією: після останнього звернення до this об’єкт може бути фіналізований під час нативного виклику. Тоді потрібен GC.KeepAlive(this).

Як перевірити в продакшені

  • SOS-команда finalizequeue у dotnet-dump analyze показує, скільки об’єктів чекає на фіналізацію.
  • Стек потоку фіналізації в дампі показує, на чому він завис.

Підсумок: власний фіналізатор пишуть лише в нащадку SafeHandle, решта класів володіє ним через Dispose.

Приклад: класичний Dispose pattern
using System.Runtime.InteropServices;
public class LegacyNativeBuffer : IDisposable
{
private IntPtr _ptr = Marshal.AllocHGlobal(1024); // некерована пам'ять
private readonly MemoryStream _log = new(); // керований ресурс
private bool _disposed;
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // фіналізатор більше не потрібен
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing) _log.Dispose(); // кероване — лише з Dispose()
Marshal.FreeHGlobal(_ptr); // некероване — завжди
_ptr = IntPtr.Zero;
_disposed = true;
}
~LegacyNativeBuffer() => Dispose(disposing: false); // страховка, якщо забули Dispose
}
Приклад: SafeHandle замість власного фіналізатора
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
// Фіналізатор уже є в SafeHandle; ReleaseHandle викличеться рівно один раз
sealed class HGlobalHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public HGlobalHandle(int size) : base(ownsHandle: true)
=> SetHandle(Marshal.AllocHGlobal(size));
protected override bool ReleaseHandle()
{
Marshal.FreeHGlobal(handle);
return true;
}
}
// Власник: без фіналізатора й без Dispose(bool)
public sealed class NativeBuffer : IDisposable
{
private readonly HGlobalHandle _handle = new(1024);
public void Dispose() => _handle.Dispose(); // повторний виклик безпечний
}
Middle+

Як у керованому коді виникають витоки пам’яті і як їх шукати?

Section titled “Як у керованому коді виникають витоки пам’яті і як їх шукати?”
Відповідь

Що це

Витік пам’яті — непотрібні об’єкти, на які ще є посилання. GC видаляє лише недосяжні об’єкти, тому ця пам’ять не звільняється.

Звідки береться

  • підписка на подію без відписки: видавець тримає посилання на підписника;
  • static-список чи словник, з якого нічого не видаляють;
  • кеш без обмеження розміру й терміну життя;
  • таймери без Dispose.

Як помітити

  1. Пам’ять процесу росте й не падає навіть під час простою.
  2. У Visual Studio (Diagnostic Tools → Memory Usage) зробіть знімок купи.
  3. Повторіть дію, зробіть другий знімок і порівняйте, яких об’єктів стало більше.

Типова помилка

Вважати, що Dispose прибирає об’єкт з пам’яті. Якщо на нього посилається подія чи колекція, він лишиться в купі.

Як влаштовано

  • Події. Делегат зберігає посилання на підписника. Якщо видавець живе довше (синглтон, статична подія), підписник не збереться.
  • Статика й кеші. static Dictionary або ConcurrentDictionary у ролі кешу без витіснення росте безмежно.
  • Таймери. Активний таймер зареєстрований у черзі таймерів рантайму, а його callback тримає об’єкт-власник. Якщо власник зберігає таймер у полі, обидва живуть до Dispose таймера.
  • Замикання. Лямбда тримає захоплені змінні й this. Збережена в події, кеші чи статичному полі, вона тримає весь граф.
  • DI. Transient-сервіс з IDisposable з кореневого провайдера контейнер зберігає до свого Dispose.

Коли що обирати

Інструмент Що дає
dotnet-counters чи росте купа після збирань
dotnet-gcdump граф об’єктів: порівняння за типами, шлях до кореня
dotnet-dump повний дамп: dumpheap -stat, gcroot

Пастки

  • Dispose не прибирає посилання на об’єкт з колекцій і подій.
  • Відписка потрібна від подій довгоживучого видавця. Цикл посилань усередині недосяжного графа GC збере сам.

Правило

Шукайте за кроками:

  1. dotnet-counters monitor -p <pid> — переконатися, що розмір купи після збирань росте.
  2. Два знімки dotnet-gcdump collect -p <pid> з інтервалом; відкрити у Visual Studio або PerfView і порівняти за типами.
  3. Знайти, хто тримає тип, що росте: Paths to Root у Visual Studio або gcroot у dotnet-dump analyze.

Під капотом

  • Спільне замикання. Компілятор створює один клас замикання на область видимості. Якщо дві лямбди в методі захоплюють різні змінні, обидві змінні лежать в одному об’єкті, і довгоживуча лямбда тримає чужий великий об’єкт.
  • ExecutionContext. Таймери й робота в пулі потоків захоплюють контекст виконання, а з ним значення AsyncLocal<T>. Таймер, створений усередині запиту, тримає дані цього запиту.
  • Корені. gcroot закінчує ланцюжок на корені: статичному полі, сильному чи закріпленому GC-хендлі, черзі фіналізації.

Ціна

  • dotnet-gcdump спричиняє повне збирання, яке на великій купі надовго зупиняє процес.
  • dotnet-dump містить усю пам’ять процесу зі значеннями полів і важить стільки ж.

Компроміси

.gcdump легший і безпечніший для продакшену, але в ньому немає значень полів. Повний дамп потрібен, коли важливі дані, наприклад який ключ росте в кеші.

Як перевірити в продакшені

  1. Порівняти dotnet.gc.last_collection.heap.size і dotnet.process.memory.working_set (назви .NET 9+). Росте лише working set — витік нативний.
  2. Навантажити, дочекатися сталого стану, зняти дамп; повторити за кілька хвилин.
  3. Порівняти dumpheap -stat або diff двох .gcdump: цікавий тип, у якого росте кількість екземплярів.
  4. Виконати gcroot для екземпляра цього типу й знайти корінь.

У тестах витік ловлять так: створити об’єкт в окремому методі, тримати на нього WeakReference, викликати GC.Collect() і перевірити IsAlive.

Підсумок: спершу відокремити нативний витік від керованого, потім знайти корінь, який тримає тип, що росте.

Приклад
public sealed class PriceFeed // синглтон, живе весь час роботи
{
public event EventHandler<decimal>? PriceChanged;
public void Publish(decimal price) => PriceChanged?.Invoke(this, price);
}
public sealed class PriceWidget : IDisposable // створюється на кожен екран
{
private readonly PriceFeed _feed;
private readonly byte[] _cache = new byte[100_000]; // витече разом з віджетом
public PriceWidget(PriceFeed feed)
{
_feed = feed;
_feed.PriceChanged += OnPrice; // feed → делегат → this
}
private void OnPrice(object? sender, decimal price) => Console.WriteLine(price);
// Без відписки кожен закритий віджет лишається в пам'яті назавжди
public void Dispose() => _feed.PriceChanged -= OnPrice;
}
Пастка: спільне замикання · Senior
static void Subscribe(PriceFeed feed)
{
var report = new byte[10_000_000]; // великий тимчасовий об'єкт
var name = "EURUSD";
Task.Run(() => Console.WriteLine(report.Length)); // коротка лямбда
feed.PriceChanged += (_, p) => Console.WriteLine($"{name}: {p}");
// Обидві лямбди ділять один клас замикання: довгоживучий
// обробник події тримає і name, і report (10 МБ)
}
Senior+

Які режими GC бувають і як обрати?

Section titled “Які режими GC бувають і як обрати?”
Відповідь

Що це

  • Workstation GC — одна купа, збирання йде просто на потоці, який виділяв пам’ять. За замовчуванням для консольних і десктопних застосунків.
  • Server GC — своя купа й свій потік збирання на кожен процесор. За замовчуванням в ASP.NET Core.
  • Фонове збирання (background GC) увімкнене в обох режимах: довге збирання покоління 2 йде паралельно з роботою застосунку.

Навіщо

Server GC збирає швидше й дає більшу пропускну здатність, але витрачає більше пам’яті. Workstation ощадливіший.

Як користуватися

Режим задається в .csproj: <ServerGarbageCollection>true</ServerGarbageCollection>.

Типова помилка

Змінювати режим GC «для прискорення» без вимірювань. Значення за замовчуванням підходять більшості застосунків.

Як влаштовано

Workstation Server
Купи одна по одній на логічний процесор
Де йде збирання на потоці, що його викликав на виділених потоках GC (на Windows — THREAD_PRIORITY_HIGHEST)
Пам’ять менше більше
  • На машині з одним логічним процесором завжди використовується Workstation.
  • Background GC (System.GC.Concurrent, за замовчуванням true) працює лише для покоління 2: виділений потік маркує й підмітає, поки застосунок працює. Фонове збирання не ущільнює купу.
  • Якщо під час фонового збирання заповнилося покоління 0, минає звичайне ефемерне збирання із зупинкою потоків.
  • DATAS з’явився в .NET 8 як опція, із .NET 9 увімкнений за замовчуванням для Server GC. Він стартує з однієї купи, додає й прибирає купи за навантаженням і обмежує бюджет покоління 0 за обсягом довгоживучих даних.

Коли що обирати

Сценарій Режим
Вебсервіс, контейнери, змінне навантаження Server GC з DATAS (за замовчуванням)
Виділена машина, важливі старт і пікова пропускна здатність Server GC без DATAS
Десктоп, консольна утиліта Workstation з фоновим збиранням
Сотні невеликих процесів на одній машині Workstation без фонового збирання

Пастки

  • Налаштування читаються один раз під час старту: змінювати змінні середовища в процесу, що працює, марно.
  • Явний GCHeapCount вимикає DATAS.

Правило

Задавайте режим властивостями ServerGarbageCollection і ConcurrentGarbageCollection у .csproj (або System.GC.Server і System.GC.Concurrent у runtimeconfig.json) і змінюйте його лише за результатами вимірювань.

Під капотом

  • DATAS орієнтується на throughput cost percentage (TCP) — частку втрат на паузи GC і очікування алокацій. Ціль за замовчуванням — 2 % (GCDTargetTCP).
  • Верхня межа бюджету покоління 0 рахується від розміру застосунку. У .NET 10 для неї додано налаштування GCDGen0GrowthPercent, GCDGen0GrowthMinFactor і GCDGen0GrowthMaxFactor.
  • У контейнері GC вважає ліміт контейнера фізичною пам’яттю. Без налаштувань GCHeapHardLimit дорівнює більшому з 20 МБ і 75 % ліміту, а LOH за жорсткого ліміту ущільнюється автоматично.

Ціна

  • З DATAS спочатку одна купа, тому старт повільніший, а за різкого росту навантаження потоки чекають на алокації.
  • Без DATAS Server GC підбирає бюджети за пропускною здатністю, і на машині з великою кількістю ядер купа роздувається.
  • Коли досягнуто GCHeapHardLimit, алокація завершується OutOfMemoryException.

Компроміси

  • DATAS вимикають, якщо критичний старт, неприпустима навіть невелика втрата пропускної здатності або збирання переважно повні. Способи: <GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>, System.GC.DynamicAdaptationMode = 0 або DOTNET_GCDynamicAdaptationMode=0.
  • GCConserveMemory (0–9) тримає купу меншою ціною частіших збирань.
  • GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency пригнічує блокувальні повні збирання на час критичної роботи, але купа росте.

Як перевірити в продакшені

  • Навантажувальний тест з реальним профілем: dotnet.gc.pause.time, кількість збирань, working set, RPS і p99.
  • PerfView GCStats: частка пауз і бюджет покоління 0 для кожного збирання.
  • Для багатьох .NET 10 — перший LTS з DATAS за замовчуванням, тому після міграції з .NET 8 порівняйте метрики. Змінюйте по одному налаштуванню за раз.

Підсумок: режим GC обирають за метриками пауз, пам’яті й пропускної здатності, а не за назвою.

Приклад
using System.Runtime;
// Режим із проєкту: <ServerGarbageCollection>true</ServerGarbageCollection>
Console.WriteLine($"Server GC: {GCSettings.IsServerGC}"); // Server GC: True
Console.WriteLine($"Latency mode: {GCSettings.LatencyMode}"); // Interactive, якщо фонове збирання ввімкнене
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"Доступно GC: {info.TotalAvailableMemoryBytes / 1024 / 1024} МБ"); // у контейнері — від ліміту
Console.WriteLine($"Поріг високого навантаження: {info.HighMemoryLoadThresholdBytes / 1024 / 1024} МБ");
// Фактичні налаштування GC (.NET 7+): кількість куп, ліміти, DATAS тощо
foreach (var (key, value) in GC.GetConfigurationVariables())
Console.WriteLine($"{key} = {value}");