Збирання сміття й ресурси
Як .NET керує пам’яттю й ресурсами: як GC знаходить сміття, навіщо йому покоління, чим Dispose відрізняється від фіналізатора, звідки беруться витоки і який режим GC обрати. На співбесіді тема перевіряє, чи розумієте ви ціну алокацій і час життя об’єктів.
Перед цією темою: Типи й пам’ять.
Як працює збирач сміття в .NET?
Section titled “Як працює збирач сміття в .NET?”Що це
Збирач сміття (GC) — частина .NET, яка сама звільняє пам’ять об’єктів. Викликати free або delete, як у C++, не потрібно.
Навіщо
Ручне звільнення пам’яті призводить до витоків і до звернення до вже звільненої пам’яті. GC прибирає обидві помилки.
Як він вирішує, що видаляти
- Корені — локальні змінні методів, що виконуються, і статичні поля.
- GC іде за посиланнями від коренів і позначає досяжні об’єкти.
- Непозначені об’єкти — сміття, навіть якщо посилаються один на одного по колу.
GC запускається сам, зазвичай під час чергового new, коли закінчилося місце для нових об’єктів.
Типова помилка
Викликати GC.Collect() «для порядку». Ручний виклик зазвичай лише сповільнює програму.
Як влаштовано
Купа поділена на покоління за віком об’єктів: у поколінні 0 нові, у поколінні 2 довгоживучі. Зазвичай збирається лише покоління 0.
- Алокація. Новий об’єкт виділяється зсувом покажчика в поколінні 0 — майже так само дешево, як на стеку. Пам’ять видається вже обнуленою.
- Зупинка. Перед блокувальним збиранням рантайм зупиняє всі керовані потоки, крім того, що його запустив.
- Маркування. GC обходить граф від коренів: локальних змінних і регістрів потоків, статичних полів, GC-хендлів, об’єктів, що чекають на фіналізацію.
- Звільнення. Мертві ділянки або прибираються ущільненням, або заносяться до списку вільних блоків (sweep).
- Продовження. Потоки відновлюють роботу.
Коли що обирати
Після маркування 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: досяжний з локальної змінної cacheConsole.WriteLine(garbage.IsAlive); // False: на масив не лишилося сильних посиланьGC.KeepAlive(cache); // cache вважається живою до цього рядка
static WeakReference MakeGarbage(){ var data = new byte[1024]; // посилання лише в локальній змінній return new WeakReference(data); // слабке посилання не корінь}- Чому циклічні посилання не заважають збиранню? — GC перевіряє досяжність від коренів, а не рахує посилання.
- Чи може об’єкт бути зібраний, поки виконується його метод? — Так, в оптимізованому коді, якщо
thisдалі не використовується; допомагаєGC.KeepAlive. - Чим ущільнення відрізняється від sweep? — Ущільнення рухає об’єкти й виправляє посилання, sweep лишає діри в списку вільних блоків.
- Чому алокація в .NET дешева? — Це зсув покажчика в заздалегідь виділеній потоку області, без пошуку вільного блоку.
- Коли виправданий
GC.Collect? — У тестах, бенчмарках і діагностиці; у коді застосунку майже ніколи.
Навіщо потрібні 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 колись не дістанеться до об’єкта.
Як влаштовано
- Вираз у
usingобчислюється й зберігається в змінну лише для читання. - Тіло виконується всередині
try. - У
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: асинхронне очищення через DisposeAsyncstatic 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();}- Що буде, якщо не викликати
DisposeуFileStream? — Файл закриє фіналізаторSafeFileHandle, але невідомо коли; доти файл може лишатися заблокованим. - Чи можна викликати
Disposeдвічі? — Так, правильна реалізація ідемпотентна. - Навіщо
IAsyncDisposable, якщо єIDisposable? — Щоб не блокувати потік на I/O під час закриття, наприклад під час скидання буфера. - Чи треба викликати
Disposeу сервісу, отриманого з DI? — Ні, часом життя керує контейнер. - Чому
HttpClientне загортають уusingна кожен запит? — З’єднання закриваються не одразу, і під навантаженням закінчуються порти.
Навіщо 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); // зібрати лише покоління 0Console.WriteLine(GC.GetGeneration(obj)); // 1: пережив збирання й просунувсяGC.Collect(); // повне збиранняConsole.WriteLine(GC.GetGeneration(obj)); // 2: далі просуватися нікуди
var large = new byte[85_000]; // від 85 000 байт — одразу в LOHvar 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: 2GC.KeepAlive(large);GC.KeepAlive(buffer);- Чому поріг LOH саме 85 000 байт? — Підібраний за результатами тюнінгу; налаштовується через
GCLOHThreshold, але лише в більший бік. - Як боротися з фрагментацією LOH? — Пули (
ArrayPool<T>), поділ даних на шматки, менші за поріг, разове ущільненняCompactOnce. - Чим POH кращий за
GCHandle.Alloc(obj, GCHandleType.Pinned)? — Довгоживучі закріплені буфери не фрагментують ефемерні покоління. - Що таке write barrier? — Код, який JIT вставляє під час запису посилання в поле, щоб позначити ділянку для ефемерних збирань.
- Що означає «повне збирання»? — Збирання покоління 2 разом з усіма молодшими поколіннями, LOH і POH.
Що таке фіналізатор, Dispose pattern і до чого тут SafeHandle?
Section titled “Що таке фіналізатор, Dispose pattern і до чого тут SafeHandle?”Що це
Фіналізатор оголошується як ~ClassName(), і компілятор перетворює його на перевизначення Object.Finalize. Викликає його GC, а не ви, і в непередбачуваний момент.
Навіщо
Це страховка. Якщо клас безпосередньо тримає некерований ресурс, наприклад покажчик на нативну пам’ять, а Dispose забули, ресурс усе ж звільниться.
Як користуватися
- Dispose pattern — стандартна схема:
Dispose()і фіналізатор викликають спільний методDispose(bool disposing). - SafeHandle — готова обгортка над дескриптором ОС з власним фіналізатором (
SafeFileHandleта інші). З нею власний фіналізатор не потрібен.
Типова помилка
Порожній фіналізатор «про всяк випадок». Він лише сповільнює програму. У структур фіналізатора бути не може.
Як влаштовано
- Під час створення об’єкт з фіналізатором реєструється в черзі фіналізації.
- Коли GC знаходить об’єкт недосяжним, запис переноситься до черги f-reachable. Об’єкт і все, на що він посилається, знову тимчасово досяжні.
- Потік фіналізації викликає
Finalize. - Наступне збирання цього покоління звільняє пам’ять.
Виходить щонайменше два збирання й просування в старше покоління.
Коли що обирати
| Клас володіє | Що реалізувати |
|---|---|
лише керованими 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.
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}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(); // повторний виклик безпечний}- Чому в
Dispose(false)не можна чіпати керовані поля? — Вони могли бути вже фіналізовані: порядок фіналізації не визначений. - Навіщо
GC.SuppressFinalize, якщо фіналізатора немає? — На випадок нащадка з фіналізатором; без фіналізатора виклик ні на що не впливає. - Що буде, якщо фіналізатор кине виняток? — Процес завершиться.
- Чи викликаються фіналізатори під час завершення застосунку? — У .NET Framework так, у .NET 5+ ні.
- Як
SafeHandleзахищає від recycle attack? — На час P/Invoke лічильник посилань не дає закрити дескриптор і віддати його номер іншому ресурсу.
Як у керованому коді виникають витоки пам’яті і як їх шукати?
Section titled “Як у керованому коді виникають витоки пам’яті і як їх шукати?”Що це
Витік пам’яті — непотрібні об’єкти, на які ще є посилання. GC видаляє лише недосяжні об’єкти, тому ця пам’ять не звільняється.
Звідки береться
- підписка на подію без відписки: видавець тримає посилання на підписника;
static-список чи словник, з якого нічого не видаляють;- кеш без обмеження розміру й терміну життя;
- таймери без
Dispose.
Як помітити
- Пам’ять процесу росте й не падає навіть під час простою.
- У Visual Studio (Diagnostic Tools → Memory Usage) зробіть знімок купи.
- Повторіть дію, зробіть другий знімок і порівняйте, яких об’єктів стало більше.
Типова помилка
Вважати, що Dispose прибирає об’єкт з пам’яті. Якщо на нього посилається подія чи колекція, він лишиться в купі.
Як влаштовано
- Події. Делегат зберігає посилання на підписника. Якщо видавець живе довше (синглтон, статична подія), підписник не збереться.
- Статика й кеші.
static DictionaryабоConcurrentDictionaryу ролі кешу без витіснення росте безмежно. - Таймери. Активний таймер зареєстрований у черзі таймерів рантайму, а його callback тримає об’єкт-власник. Якщо власник зберігає таймер у полі, обидва живуть до
Disposeтаймера. - Замикання. Лямбда тримає захоплені змінні й
this. Збережена в події, кеші чи статичному полі, вона тримає весь граф. - DI. Transient-сервіс з
IDisposableз кореневого провайдера контейнер зберігає до свогоDispose.
Коли що обирати
| Інструмент | Що дає |
|---|---|
dotnet-counters |
чи росте купа після збирань |
dotnet-gcdump |
граф об’єктів: порівняння за типами, шлях до кореня |
dotnet-dump |
повний дамп: dumpheap -stat, gcroot |
Пастки
Disposeне прибирає посилання на об’єкт з колекцій і подій.- Відписка потрібна від подій довгоживучого видавця. Цикл посилань усередині недосяжного графа GC збере сам.
Правило
Шукайте за кроками:
dotnet-counters monitor -p <pid>— переконатися, що розмір купи після збирань росте.- Два знімки
dotnet-gcdump collect -p <pid>з інтервалом; відкрити у Visual Studio або PerfView і порівняти за типами. - Знайти, хто тримає тип, що росте: Paths to Root у Visual Studio або
gcrootуdotnet-dump analyze.
Під капотом
- Спільне замикання. Компілятор створює один клас замикання на область видимості. Якщо дві лямбди в методі захоплюють різні змінні, обидві змінні лежать в одному об’єкті, і довгоживуча лямбда тримає чужий великий об’єкт.
ExecutionContext. Таймери й робота в пулі потоків захоплюють контекст виконання, а з ним значенняAsyncLocal<T>. Таймер, створений усередині запиту, тримає дані цього запиту.- Корені.
gcrootзакінчує ланцюжок на корені: статичному полі, сильному чи закріпленому GC-хендлі, черзі фіналізації.
Ціна
dotnet-gcdumpспричиняє повне збирання, яке на великій купі надовго зупиняє процес.dotnet-dumpмістить усю пам’ять процесу зі значеннями полів і важить стільки ж.
Компроміси
.gcdump легший і безпечніший для продакшену, але в ньому немає значень полів. Повний дамп потрібен, коли важливі дані, наприклад який ключ росте в кеші.
Як перевірити в продакшені
- Порівняти
dotnet.gc.last_collection.heap.sizeіdotnet.process.memory.working_set(назви .NET 9+). Росте лише working set — витік нативний. - Навантажити, дочекатися сталого стану, зняти дамп; повторити за кілька хвилин.
- Порівняти
dumpheap -statабо diff двох.gcdump: цікавий тип, у якого росте кількість екземплярів. - Виконати
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;}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 МБ)}- Чому відписуватися важливо від подій синглтона, але не від подій власного поля? — Витікає підписник довгоживучого видавця; цикл усередині недосяжного графа GC збере.
- Чим
dotnet-gcdumpвідрізняється відdotnet-dump? — gcdump — лише граф об’єктів, легкий, але з повним збиранням; dump — уся пам’ять процесу зі значеннями. - Як відрізнити витік від просто великої купи Server GC? — Дивитися на розмір купи після повних збирань за сталого навантаження: за витоку він росте без плато.
- Що таке слабка подія? — Підписка через
WeakReference, яка не заважає зібрати підписника. - Чи може витекти пам’ять за правильного
Dispose? — Так:Disposeне прибирає посилання на об’єкт з колекцій і подій.
Які режими 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: TrueConsole.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}");- Чому Server GC без DATAS у маленькому контейнері на багатоядерній машині роздуває пам’ять? — Купи й бюджети рахуються від кількості ядер і пропускної здатності, а не від обсягу даних.
- Що робить фонове збирання, якщо під час нього заповнилося покоління 0? — Призупиняється, і минає звичайне блокувальне ефемерне збирання.
- Як вимкнути DATAS і коли це виправдано? —
GCDynamicAdaptationMode=0; коли важливі старт і пікова пропускна здатність, а пам’яті вдосталь. - Що станеться, коли досягнуто
GCHeapHardLimit? — Алокація завершитьсяOutOfMemoryException, навіть якщо в машини є вільна пам’ять. - Коли потрібен
SustainedLowLatency? — На час чутливих до затримок операцій, якщо пам’яті достатньо: купа росте й фрагментується.