System Internals Concurrency

Memory Management

Глибокий розбір управління пам'яттю в .NET - Garbage Collection, IDisposable, Finalizers, WeakReference та Memory Layout

Memory Management (Управління Пам'яттю)

Вступ та Контекст

Проблема: Чому автоматичне управління пам'яттю?

Уявіть, що ви пишете програму на C або C++. Ви виділяєте пам'ять за допомогою malloc() або new, працюєте з нею, а потім... забуваєте її звільнити. Результат? Memory leak (витік пам'яті). Ваша програма поступово споживає все більше пам'яті, поки операційна система не вирішить припинити цей хаос.

З іншого боку, якщо ви звільните пам'ять надто рано або двічі, ви отримаєте dangling pointer (висячий вказівник) та undefined behavior - ваша програма може впасти в будь-який момент, часто у найнесподіваніший.

// C++ - ручне управління пам'яттю
int* CreateArray() {
    int* arr = new int[1000];
    // ... робота з масивом ...
    delete[] arr;  // Потрібно пам'ятати звільнити!
    return arr;    // ❌ ПОМИЛКА: повертаємо dangling pointer
}

void ProcessData() {
    int* data = new int[500];
    // ... складна логіка ...
    if (error) {
        return;  // ❌ Memory leak - забули delete[]
    }
    delete[] data;
}
Статистика помилок: За дослідженнями Microsoft, близько 70% вразливостей безпеки в програмному забезпеченні пов'язані з помилками управління пам'яттю (use-after-free, buffer overflows, memory corruption).

Еволюція: Від Manual до Automatic

1990-ті роки: Мови програмування вимагали ручного управління пам'яттю. Розробники витрачали значну частину часу на відлагодження memory leaks та crashes.

1995 рік - Java: Представила Garbage Collection як стандарт для enterprise-додатків. Продуктивність розробки зросла, але з'явився overhead на runtime.

2002 рік - .NET Framework 1.0: Microsoft інтегрувала покоління GC (Generational Garbage Collector) з оптимізаціями для різних сценаріїв.

2010+ роки: Continuous improvements - Server GC, Background GC, LOH compaction, і з .NET 5+ навіть Pinned Object Heap (POH).

Філософія .NET: "Безпека та продуктивність разом". Автоматичне управління пам'яттю забезпечує memory safety без критичної втрати швидкості завдяки розумним алгоритмам та оптимізаціям JIT.

Що ви дізнаєтесь

Після цього розділу ви зможете:

  • 🧠 Розуміти архітектуру: Як CLR організовує managed heap, що таке покоління, чому існує LOH/SOH/POH
  • 🔍 Аналізувати "під капотом": Як працює marking-sweeping-compaction, що відбувається під час GC
  • 🛡️ Управляти неkerованими ресурсами: Правильно реалізовувати IDisposable, розуміти різницю між Dispose та Finalizer
  • Оптимізувати продуктивність: Знати, коли використовувати WeakReference, як уникати LOH фрагментації, як налаштувати GC mode
  • 🔬 Розбиратись у memory layout: Розуміти структуру об'єктів у пам'яті, padding, alignment, overhead

Prerequisites (Передумови)

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


Фундаментальні Концепції

Managed Heap (Керована Купа)

Коли ваш .NET додаток стартує, CLR (Common Language Runtime) резервує непрерывну область віртуальної пам'яті, яка називається managed heap. Це не фізична пам'ять, а віртуальний адресний простір, який операційна система виділяє процесу.

Loading diagram...
graph LR
    A[Process Start] --> B[CLR Initialize]
    B --> C[Reserve Virtual Memory]
    C --> D[Managed Heap Created]
    D --> E[Allocation Pointer at Base]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#f59e0b,stroke:#b45309,color:#ffffff
    style E fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Як працює алокація

Managed heap підтримує allocation pointer (вказівник на наступну вільну позицію). Коли ви створюєте новий об'єкт:

var person = new Person("John", 30);

CLR виконує наступні кроки:

Крок 1: Обчислення розміру

Визначається скільки байтів потрібно для об'єкта:

  • Object header (8-16 байт залежно від платформи)
  • Method table pointer (4-8 байт)
  • Розмір всіх полів екземпляра
  • Padding для alignment

Крок 2: Перевірка доступного простору

CLR перевіряє, чи є достатньо місця від поточного allocation pointer до кінця Gen0.

Крок 3: Розміщення об'єкта

Якщо місце є:

  • Об'єкт копіюється за адресою allocation pointer
  • Allocation pointer зміщується на розмір об'єкта
  • Повертається референс на новий об'єкт

Крок 4: Тригер GC (якщо потрібно)

Якщо місця недостатньо, спрацьовує Garbage Collection для звільнення пам'яті.

// Псевдокод роботи CLR при алокації
object AllocateObject(Type type)
{
    int size = CalculateSize(type);  // Розмір з header + fields + padding

    if (allocationPointer + size > gen0End)
    {
        // Недостатньо місця - запускаємо GC
        GC.Collect(0);  // Збираємо Gen0

        if (allocationPointer + size > gen0End)
        {
            // Все ще недостатньо - розширюємо heap
            ExpandHeap();
        }
    }

    // Розміщуємо об'єкт
    void* address = allocationPointer;
    InitializeObject(address, type);
    allocationPointer += size;

    return (object)address;
}
Швидкість алокації: У managed heap алокація об'єкта - це просто додавання значення до вказівника, що майже так само швидко, як алокація на стеку! Це контрастує з malloc() у C, який повинен шукати вільний блок у складних структурах даних.

Stack vs Heap: Детальне Порівняння

ХарактеристикаStack (Стек)Heap (Купа)
Що зберігаєтьсяValue types, локальні змінні, параметри методівReference types (об'єкти класів, масиви, strings)
РозмірОбмежений (1-4 MB за замовчуванням)Обмежений тільки доступною RAM та віртуальною пам'яттю
Швидкість доступуДуже швидко (CPU cache-friendly)Повільніше (потрібна індирекція через референс)
Час життяДо завершення методу (scope-based)До збору GC (недосяжні об'єкти)
АлокаціяПереміщення stack pointerПереміщення allocation pointer + можливий GC
ДеалокаціяАвтоматично при виході з scopeАвтоматично GC (недетерміновано)
ФрагментаціяНеможлива (LIFO структура)Можлива (розв'язується compaction)
Thread safetyКожен thread має свій stackShared між threads (потрібна синхронізація)
void StackVsHeapDemo()
{
    // Stack: value type
    int x = 42;  // 4 байти на стеку

    // Stack: struct (якщо не boxed)
    Point p = new Point(10, 20);  // 8 байт на стеку

    // Heap: reference type
    Person person = new Person();  // Референс (4/8 байт) на стеку
                                    // Об'єкт на heap

    // Heap: boxing
    object boxedX = x;  // Створює об'єкт на heap з значенням 42

} // <-- При виході: stack очищується автоматично
  //     Heap об'єкти стають eligible for GC
Loading diagram...
graph TD
    subgraph Stack
        A[Method Frame] --> B[int x = 42]
        A --> C[Point p struct]
        A --> D[Person person reference]
    end

    subgraph Heap
        E[Person Object]
        F[Boxed int Object]
    end

    D -->|points to| E

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style E fill:#f59e0b,stroke:#b45309,color:#ffffff
    style F fill:#f59e0b,stroke:#b45309,color:#ffffff

Value Types vs Reference Types у Контексті Пам'яті

Value Types (Типи Значень)

Зберігаються inline - безпосередньо там, де оголошені:

struct Point
{
    public int X;  // 4 байти
    public int Y;  // 4 байти
    // Загалом: 8 байт
}

class Container
{
    public int id;        // 4 байти inline в об'єкті
    public Point point;   // 8 байт inline в об'єкті
    // Загалом в об'єкті: Object Header + MethodTable + 4 + 8 = ~28-40 байт
}

void Method()
{
    Point p;  // 8 байт на стеку
    Container c = new Container();  // Референс на стеку, об'єкт на heap
}

Reference Types (Типи Посилань)

Зберігаються тільки на heap. Змінна містить референс (адресу):

class Person
{
    public string Name;  // Референс на string (який теж на heap)
    public int Age;      // 4 байти inline
}

Person p1 = new Person();  // Heap: Person об'єкт
Person p2 = p1;            // Stack: обидві змінні вказують на ОДИН об'єкт

p2.Age = 30;
Console.WriteLine(p1.Age);  // 30 - змінили через p2, але p1 бачить зміни!
Типова помилка: Програмісти, які прийшли з C/C++, часто плутають референси в C# з вказівниками. Референси в C# безпечніші - ви не можете виконувати pointer arithmetic, і вони завжди вказують на валідний об'єкт або null.

Boxing та Unboxing: Вплив на GC

Boxing - це процес перетворення value type у reference type (алокація на heap).

int value = 42;           // Stack: 4 байти
object boxed = value;     // Heap: створюється об'єкт Int32 з value 42
                          // Stack: референс на цей об'єкт

// Unboxing - зворотній процес
int unboxed = (int)boxed; // Копіювання значення з heap на stack
Loading diagram...
sequenceDiagram
    participant Stack
    participant Heap as Managed Heap
    participant GC as Garbage Collector

    Note over Stack: int value = 42
    Stack->>Stack: Allocate 4 bytes

    Note over Stack,Heap: object boxed = value
    Stack->>Heap: Request boxing
    Heap->>Heap: Allocate Int32 object
    Heap->>Heap: Copy value 42
    Heap-->>Stack: Return reference

    Note over GC: Boxed object тепер під управлінням GC

    Note over Stack: int unboxed = (int)boxed
    Stack->>Heap: Read value from boxed object
    Heap-->>Stack: Copy value 42

    Note over GC: Якщо немає інших refs до boxed<br/>об'єкт eligible for collection

    style Stack fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style Heap fill:#f59e0b,stroke:#b45309,color:#ffffff
    style GC fill:#64748b,stroke:#334155,color:#ffffff

Performance implications:

// ❌ Погано: Boxing у циклі
List<object> items = new List<object>();
for (int i = 0; i < 10000; i++)
{
    items.Add(i);  // 10000 boxing операцій = 10000 heap алокацій!
}

// ✅ Добре: Використання generic колекції
List<int> items = new List<int>();
for (int i = 0; i < 10000; i++)
{
    items.Add(i);  // Без boxing, зберігається inline
}
Коли відбувається boxing:
  • Присвоєння value type до object або interface
  • Виклик ToString(), GetHashCode(), Equals() на value type
  • Використання value type у non-generic колекціях (ArrayList, Hashtable)
  • LINQ запити над collections value types (якщо не оптимізовані)

Garbage Collection: Серце Автоматичного Управління

Що таке Garbage Collector?

Garbage Collector (GC) - це компонент CLR, який автоматично керує пам'яттю у managed heap. Його основні завдання:

  1. Визначити недосяжні об'єкти (garbage)
  2. Звільнити пам'ять, яку вони займають
  3. Компактувати heap для ефективного використання пам'яті
Термінологія: Об'єкт вважається "garbage" (сміттям), якщо до нього немає жодних "живих" посилань від roots (локальні змінні, статичні поля, CPU registers, finalization queue).

Фази Garbage Collection

GC працює у три основні фази:

Loading diagram...
graph TD
    A[GC Triggered] --> B[Phase 1: Marking]
    B --> C[Phase 2: Compacting]
    C --> D[Phase 3: Updating References]
    D --> E[GC Complete]

    B1[Identify Roots] --> B2[Walk Object Graph]
    B2 --> B3[Mark Reachable Objects]

    C1[Determine Live Objects] --> C2[Move to Beginning of Heap]
    C2 --> C3[Free Space at End]

    D1[Update All References] --> D2[Point to New Addresses]

    B --> B1
    B1 --> B2
    B2 --> B3

    C --> C1
    C1 --> C2
    C2 --> C3

    D --> D1
    D1 --> D2

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#f59e0b,stroke:#b45309,color:#ffffff
    style E fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Phase 1: Marking (Маркування)

GC проходить граф об'єктів, починаючи з roots та позначає всі досяжні об'єкти:

class Node
{
    public Node Next;
    public string Data;
}

void Example()
{
    Node head = new Node { Data = "A" };  // Root
    head.Next = new Node { Data = "B" };
    head.Next.Next = new Node { Data = "C" };
    Node orphan = new Node { Data = "X" };  // Недосяжний після наступного рядка
    orphan = null;

    // GC marking:
    // 1. Знайти roots: head (локальна змінна)
    // 2. Маркувати Node "A" як reachable
    // 3. Слідувати за Next, маркувати "B"
    // 4. Слідувати за Next, маркувати "C"
    // 5. Node "X" не маркований -> garbage
}

Roots включають:

  • Локальні змінні у активних методах (на stack)
  • Статичні поля
  • CPU registers
  • GC handles
  • Finalization queue

Phase 2: Compacting (Компактування)

Після marking GC переміщує всі живі об'єкти до початку heap, усуваючи фрагментацію:

Before Compaction:
[Obj A][    ][Obj B][        ][Obj C][    ]
 ^live  ^dead ^live   ^dead   ^live  ^dead

After Compaction:
[Obj A][Obj B][Obj C][                    ]
                      ^ Free space for new objects
Performance Impact: Compaction вимагає копіювання об'єктів у пам'яті та оновлення всіх посилань. Це може бути expensive операція для великих heaps, тому GC використовує покоління для оптимізації.

Phase 3: Updating References (Оновлення Посилань)

Після переміщення об'єктів GC оновлює всі посилання на нові адреси:

// До compaction
Node n1 = /* 0x1000 */;
Node n2 = /* 0x2000 */;
n1.Next = n2;  // Next вказує на 0x2000

// Після compaction
// n2 переміщений на 0x1100
// GC автоматично оновлює:
n1.Next = /* тепер 0x1100 */;

Покоління (Generations): Оптимізація через Спостереження

Generational Hypothesis (Гіпотеза Поколінь)

.NET GC базується на емпіричному спостереженні:

Більшість об'єктів живуть дуже коротко, а ті що виживають - живуть довго.

Це означає:

  • 🆕 Нещодавно створені об'єкти швидше за все стануть garbage
  • ⏳ Об'єкти, які пережили декілька GC циклів, ймовірно будуть жити довго

На основі цього спостереження .NET використовує три покоління:

ПоколінняПризначенняТипові Об'єктиЧастота Збору
Gen0Молоді об'єктиТимчасові змінні, буфери, короткоживучі об'єктиДуже часто
Gen1Буфер між Gen0 і Gen2Об'єкти середньої тривалості життяРідше ніж Gen0
Gen2Старі об'єктиСтатичні дані, довгоживучі об'єкти, кешіРідко
Loading diagram...
graph TD
    A[New Object Allocated] --> B[Placed in Gen0]
    B --> C{Gen0 GC Survives?}
    C -->|Yes| D[Promoted to Gen1]
    C -->|No| Z[Collected]

    D --> E{Gen1 GC Survives?}
    E -->|Yes| F[Promoted to Gen2]
    E -->|No| Z

    F --> G{Gen2 GC Survives?}
    G -->|Yes| F
    G -->|No| Z

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#f59e0b,stroke:#b45309,color:#ffffff
    style F fill:#f59e0b,stroke:#b45309,color:#ffffff
    style Z fill:#64748b,stroke:#334155,color:#ffffff

Generation 0 (Gen0): Ясла для Об'єктів

Характеристики:

  • 📏 Розмір: Зазвичай 256 KB - 4 MB (залежить від платформи та налаштувань)
  • Швидкість: Найшвидший збір (мілісекунди)
  • 📊 Частота: Найчастіше (може бути десятки разів на секунду у high-load додатках)
void Gen0Example()
{
    for (int i = 0; i < 1000; i++)
    {
        // Кожна ітерація створює об'єкти у Gen0
        var temp = new StringBuilder();
        temp.Append("Temporary data ");
        temp.Append(i);
        string result = temp.ToString();

        // temp та проміжні string об'єкти стають garbage
        // після завершення ітерації
    }
    // Під час циклу може статися декілька Gen0 collections
}

Коли спрацьовує Gen0 GC:

  1. Gen0 budget вичерпано (досягнуто ліміт розміру)
  2. Явний виклик GC.Collect(0)
  3. Системі бракує пам'яті (low memory notification)
class Gen0PressureDemo
{
    static void Main()
    {
        // Відслідковуємо GC події
        int gen0Before = GC.CollectionCount(0);

        // Створюємо багато короткоживучих об'єктів
        for (int i = 0; i < 100_000; i++)
        {
            var obj = new byte[1024];  // 1 KB об'єкт
            // obj стає garbage одразу після ітерації
        }

        int gen0After = GC.CollectionCount(0);

        Console.WriteLine($"Gen0 collections: {gen0After - gen0Before}");
        // Вивід: Gen0 collections: 25 (приблизно, залежить від heap size)
    }
}

Generation 1 (Gen1): Проміжна Зона

Призначення: Gen1 діє як буфер між часто зібраним Gen0 та рідко зібраним Gen2.

Характеристики:

  • 📏 Розмір: Більший ніж Gen0, але менший ніж Gen2 (зазвичай кілька MB)
  • Швидкість: Швидше ніж Gen2, повільніше ніж Gen0
  • 📊 Частота: Середня - збирається коли Gen0 збір не звільнив достатньо пам'яті
class Gen1Scenario
{
    private List<string> _cache = new List<string>();

    void ProcessRequests()
    {
        for (int i = 0; i < 100; i++)
        {
            string data = FetchData(i);  // Gen0

            // Деякі дані кешуються - виживуть Gen0 GC
            if (i % 10 == 0)
            {
                _cache.Add(data);  // Переміститься до Gen1, потім Gen2
            }

            ProcessData(data);  // Більшість data стане garbage в Gen0
        }
    }
}

Коли спрацьовує Gen1 GC:

  • Коли Gen0 GC не звільнило достатньо пам'яті
  • Виклик GC.Collect(1) - збере Gen0 та Gen1
  • Gen1 budget вичерпано
Оптимізація: GC рідше збирає Gen1, тому що:
  1. Більшість garbage вже зібрано в Gen0
  2. Об'єкти в Gen1 мають вищу ймовірність використання
  3. Вартість збору Gen1 вища через більший розмір

Generation 2 (Gen2): Довгожителі

Характеристики:

  • 📏 Розмір: Може бути гігабайти
  • Швидкість: Найповільніший збір (може бути десятки-сотні мілісекунд)
  • 📊 Частота: Рідко - тільки при дефіциті пам'яті

Типові об'єкти в Gen2:

  • Статичні колекції та кеші
  • Singleton instances
  • Глобальні налаштування
  • Довгоживучі сервіси
// Статичний кеш - швидко потрапить у Gen2
public static class ApplicationCache
{
    // Ці об'єкти будуть жити весь час роботи додатку
    private static Dictionary<string, UserProfile> _userCache
        = new Dictionary<string, UserProfile>();

    private static ConnectionPool _connections = new ConnectionPool();

    // Gen2 об'єкти!
}

// Singleton - також Gen2
public class ConfigurationManager
{
    private static readonly Lazy<ConfigurationManager> _instance =
        new Lazy<ConfigurationManager>(() => new ConfigurationManager());

    public static ConfigurationManager Instance => _instance.Value;  // Gen2
}

Full GC (Gen2 Collection):

Коли CLR збирає Gen2, відбувається Full GC - збір всіх поколінь (Gen0, Gen1, Gen2):

void FullGCDemo()
{
    Console.WriteLine("Before GC:");
    Console.WriteLine($"Gen0: {GC.CollectionCount(0)}");
    Console.WriteLine($"Gen1: {GC.CollectionCount(1)}");
    Console.WriteLine($"Gen2: {GC.CollectionCount(2)}");

    // Full GC - збере всі покоління
    GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);

    Console.WriteLine("\nAfter Full GC:");
    Console.WriteLine($"Gen0: {GC.CollectionCount(0)}");  // +1
    Console.WriteLine($"Gen1: {GC.CollectionCount(1)}");  // +1
    Console.WriteLine($"Gen2: {GC.CollectionCount(2)}");  // +1
}
Performance Warning: Full GC - це дорога операція! Він:
  • Зупиняє всі application threads (Stop-The-World pause)
  • Сканує весь managed heap
  • Може компактувати великі об'єми пам'яті
  • Може тривати 100ms+ для великих heaps (4+ GB)
Уникайте частих Full GC! Моніторьте через Performance Counters або GC.CollectionCount(2).

Promotion (Просування між Поколіннями)

Об'єкти не переміщуються між поколіннями фізично при кожному GC. Натомість GC відслідковує boundaries (межі) між поколіннями:

Loading diagram...
graph LR
    subgraph "Managed Heap Layout"
        A[Gen2 Objects]
        B[Gen1 Objects]
        C[Gen0 Objects]
        D[Free Space]
    end

    A --> B
    B --> C
    C --> D

    style A fill:#64748b,stroke:#334155,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#e5e7eb,stroke:#9ca3af,color:#000000

Механізм promotion:

Initial State:
[Gen2][Gen1][Gen0: Obj A | Obj B | Obj C][Free]
              ^live   ^dead  ^live

After Gen0 GC:
[Gen2][Gen1: Obj A | Obj C][Gen0][Free]
     Gen1 boundary moved ↑

After Another Gen0+Gen1 GC (Obj A та C survive):
[Gen2: Obj A | Obj C][Gen1][Gen0][Free]
     Gen2 boundary moved ↑

Важливо: Promotion не вимагає копіювання - просто оновлюються generation boundaries. Це робить процес дуже ефективним.

Профілювання: Використовуйте dotMemory, PerfView або Visual Studio Profiler для аналізу, які об'єкти досягають Gen2. Якщо багато короткоживучих об'єктів потрапляють у Gen2, це може вказувати на проблему (наприклад, тримання посилань на тимчасові об'єкти).

Large Object Heap (LOH) vs Small Object Heap (SOH)

Чому LOH Існує?

Компактування великих об'єктів (>85,000 байт) дороге з точки зору CPU:

  • Копіювання мегабайтів даних
  • Оновлення всіх посилань

Тому .NET розділяє heap на дві частини:

ХарактеристикаSOH (Small Object Heap)LOH (Large Object Heap)
Розмір об'єктів< 85,000 байт≥ 85,000 байт
КомпактуванняЗавжди (за замовчуванням)Ніколи (за замовчуванням до .NET 4.5.1)
ПоколінняGen0, Gen1, Gen2Тільки Gen2
АлокаціяSequential (послідовно)Free list (пошук вільного блоку)
ФрагментаціяМінімальна (завдяки compaction)Можлива (без compaction)

Поріг 85,000 Байт

// SOH - малий об'єкт
byte[] small = new byte[84_999];  // Розташується в SOH
Console.WriteLine(GC.GetGeneration(small));  // 0 (Gen0)

// LOH - великий об'єкт
byte[] large = new byte[85_000];  // Розташується в LOH
Console.WriteLine(GC.GetGeneration(large));  // 2 (LOH завжди Gen2)
Чому саме 85,000? Це емпірично визначене значення Microsoft. Компактування об'єктів більше цього розміру стає неефективним через витрати на копіювання пам'яті. Число 85,000 = 85 KB приблизно відповідає розміру, де overhead копіювання перевищує вигоду від уникнення фрагментації.

Проблема: Фрагментація LOH

LOH використовує free list allocation - пошук вільного блоку достатнього розміру:

LOH Before Allocation:
[Large Obj A: 100KB][Free: 50KB][Obj B: 200KB][Free: 150KB][Obj C: 80KB]

Allocate 120KB object:
Need 120KB, but largest free block is 150KB
[Obj A][Free][Obj B][New Obj: 120KB][Free: 30KB][Obj C]

Проблема: free block 50KB може ніколи не бути використаний,
якщо всі нові об'єкти >50KB

Наслідки фрагментації:

  • Неефективне використання пам'яті
  • OutOfMemoryException навіть при достатній загальній вільній пам'яті
  • Погіршення performance через пошук вільних блоків
class LOHFragmentationDemo
{
    static void Main()
    {
        List<byte[]> allocated = new List<byte[]>();

        // Алокуємо багато великих об'єктів
        for (int i = 0; i < 100; i++)
        {
            allocated.Add(new byte[100_000]);  // 100 KB
        }

        // Звільняємо кожен другий
        for (int i = 0; i < allocated.Count; i += 2)
        {
            allocated[i] = null;
        }

        // Тепер LOH фрагментований:
        // [Obj][Free 100KB][Obj][Free 100KB][Obj]...

        GC.Collect(2, GCCollectionMode.Forced);

        // Спробуємо алокувати об'єкт більше ніж 100KB
        try
        {
            var huge = new byte[150_000];  // Може зайняти час через пошук
        }
        catch (OutOfMemoryException)
        {
            Console.WriteLine("LOH fragmented - cannot allocate!");
        }
    }
}
Коли НЕ компактувати LOH:
  • Якщо є pinned об'єкти в LOH (компактування неможливе)
  • У real-time додатках, де паузи GC критичні
  • Якщо LOH рідко фрагментується (проаналізуйте metrics)
Коли компактувати:
  • Після масового звільнення великих об'єктів
  • При виявленні фрагментації (OutOfMemoryException при достатній пам'яті)
  • У batch processes чи під час maintenance windows

Pinned Object Heap (POH) - .NET 5+

З .NET 5 додано нове покоління - Pinned Object Heap:

Призначення: Зберігання pinned об'єктів окремо від SOH/LOH для уникнення фрагментації.

Що таке Pinned Objects?

unsafe void PinningExample()
{
    byte[] buffer = new byte[1000];

    // Pinning - фіксуємо об'єкт у пам'яті (не може бути переміщений GC)
    fixed (byte* ptr = buffer)
    {
        // Викликаємо native функцію, яка очікує вказівник
        NativeApi.ProcessData(ptr, buffer.Length);

        // Під час цього buffer НЕ МОЖЕ бути переміщений compaction!
    }
    // Після виходу з fixed, об'єкт unpinned
}

// Альтернатива: GCHandle
void PinWithGCHandle()
{
    byte[] buffer = new byte[1000];

    GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
    try
    {
        IntPtr ptr = handle.AddrOfPinnedObject();
        NativeApi.ProcessData(ptr, buffer.Length);
    }
    finally
    {
        handle.Free();  // Unpinning
    }
}

Проблема з pinned об'єктами в SOH:

Heap перед GC compaction:
[Obj A][Pinned B][Obj C][Free]

Compaction неможливий для B, тому:
[Obj A][Pinned B][  ][Obj C][Free]
              ↑ gap - фрагментація!

POH вирішення:

SOH:  [Obj A][Obj C][Free]  ← Може бути компактований
POH:  [Pinned B][Pinned D]  ← Окремий heap для pinned об'єктів
Best Practice: Використовуйте GC.AllocateArray<T> для створення pinned масивів напряму в POH:
// .NET 5+
byte[] pinnedBuffer = GC.AllocateArray<byte>(1000, pinned: true);

// Цей масив відразу у POH, не потребує GCHandle!

Порівняння Heaps:

HeapРозмір Об'єктівCompactionПоколінняUse Case
SOH< 85 KBТакGen0/1/2Звичайні об'єкти
LOH≥ 85 KBЗа запитомGen2Великі буфери, масиви
POH (.NET 5+)Будь-якийНіGen2Pinned об'єкти для interop

Типи Garbage Collection

.NET надає різні режими роботи GC для різних сценаріїв використання.

Workstation GC (Десктопний Режим)

Призначення: Оптимізований для клієнтських додатків (desktop, mobile).

Характеристики:

  • 🖥️ Виконується на тому ж треді, що і application code
  • ⏱️ Мінімізація пауз (низька latency)
  • 💡 Менше споживання пам'яті
  • 🔄 Підтримує concurrent/background GC (за замовчуванням)

Конфігурація:

<PropertyGroup>
    <ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>

Use Case:

// WPF/WinForms Desktop Application
public partial class MainWindow : Window
{
    // Workstation GC - низькі паузи для responsive UI
    private async void LoadData_Click(object sender, RoutedEventArgs e)
    {
        // GC може відбутись під час роботи,
        // але паузи короткі -> UI responsive
        var data = await DataService.FetchLargeDataset();
        DataGrid.ItemsSource = data;
    }
}

Server GC (Серверний Режим)

Призначення: Оптимізований для високопродуктивних серверних додатків.

Характеристики:

  • 🚀 Багатопоточний GC - по одному треду на кожне logical core
  • ⚡ Вищий throughput (більше операцій за одиницю часу)
  • 💾 Використовує більше пам'яті (кожен тред має свій heap segment)
  • ⏳ Потенційно довші паузи, але рідше

Як працює:

Loading diagram...
graph TD
    subgraph "Server GC - Multicore"
        A[Application Threads] -->|Object Allocation| B[Heap Segment 1]
        A -->|Object Allocation| C[Heap Segment 2]
        A -->|Object Allocation| D[Heap Segment 3]
        A -->|Object Allocation| E[Heap Segment 4]
    end

    subgraph "GC Triggered"
        F[GC Thread 1] -->|Collect| B
        G[GC Thread 2] -->|Collect| C
        H[GC Thread 3] -->|Collect| D
        I[GC Thread 4] -->|Collect| E
    end

    B --> F
    C --> G
    D --> H
    E --> I

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style F fill:#f59e0b,stroke:#b45309,color:#ffffff
    style G fill:#f59e0b,stroke:#b45309,color:#ffffff
    style H fill:#f59e0b,stroke:#b45309,color:#ffffff
    style I fill:#f59e0b,stroke:#b45309,color:#ffffff

Конфігурація:

<PropertyGroup>
    <ServerGarbageCollection>true</ServerGarbageCollection>

    <!-- Опціонально: кількість GC heaps (за замовчуванням = logical cores) -->
    <GCHeapCount>8</GCHeapCount>
</PropertyGroup>

Use Case:

// ASP.NET Core Web API
public class OrdersController : ControllerBase
{
    // Server GC - throughput важливіший за latency
    [HttpPost]
    public async Task<IActionResult> ProcessBulkOrders([FromBody] List<Order> orders)
    {
        // Обробка тисяч orders паралельно
        var tasks = orders.Select(ProcessOrder);
        await Task.WhenAll(tasks);

        // Server GC ефективно працює з багатопотоковим навантаженням
        return Ok();
    }
}

Порівняльна Таблиця:

ХарактеристикаWorkstation GCServer GC
ThreadsОдин GC threadПо thread на кожне core
Heap SegmentsОдин сегментКілька сегментів (per core)
Latency (Паузи)КороткіПотенційно довші
ThroughputНижчийВищий (до 2-3x)
Memory ConsumptionМеншеБільше (~20-30% більше)
Best ForUI додатки, клієнтські застосункиWeb серверы, background processes
OverheadМінімальнийВищий через multiple heaps
Як вибрати?
  • Workstation GC: WPF, WinForms, Xamarin, Blazor WASM, short-lived processes
  • Server GC: ASP.NET Core, Worker Services, batch processors, long-running services
Правило: Якщо у вас >4 cores і застосунок обробляє багато requests - використовуйте Server GC.

Background GC (Concurrent GC)

Background GC (раніше відомий як Concurrent GC) дозволяє application threads продовжувати роботу під час Gen2 collection.

Як працює:

Loading diagram...
sequenceDiagram
    participant App as Application Threads
    participant BG as Background GC Thread
    participant Heap as Managed Heap

    Note over App: Working normally
    App->>Heap: Allocate objects

    Note over BG: Gen2 GC Triggered
    BG->>BG: Suspend app threads briefly
    BG->>Heap: Mark roots
    BG->>BG: Resume app threads

    par Background Collection
        App->>Heap: Continue allocating
    and
        BG->>Heap: Mark objects (concurrent)
    end

    BG->>BG: Suspend app threads
    BG->>Heap: Final marking & compaction
    BG->>BG: Resume app threads

    Note over App: Minimal pause experienced

    style App fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style BG fill:#f59e0b,stroke:#b45309,color:#ffffff

Фази Background GC:

  1. Initial Suspend (~1-5ms): Зупинити app threads, зібрати roots
  2. Concurrent Marking (основна частина): App threads працюють, GC маркує об'єкти паралельно
  3. Final Suspend (~10-50ms): Фінальне маркування нових об'єктів та compaction

Конфігурація:

<PropertyGroup>
    <!-- За замовчуванням увімкнено, але можна вимкнути -->
    <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

Вимкнути Background GC:

// У коді (до запуску GC):
System.Runtime.GCSettings.LatencyMode = GCLatencyMode.Batch;
// Batch mode - без background, компактує агресивніше

Переваги:

  • ✅ Менші паузи для application
  • ✅ Краща responsiveness
  • ✅ Підходить для UI та interactive додатків

Недоліки:

  • ❌ Додатковий CPU overhead
  • ❌ Більше споживання пам'яті під час GC
  • ❌ Не гарантує мінімальних пауз для Gen0/Gen1
Background GC та Generations: Background GC застосовується тільки до Gen2. Gen0 та Gen1 все ще збираються в "stop-the-world" режимі, але вони дуже швидкі (<1ms зазвичай).

GC Triggers (Тригери Збирання)

1. Allocation Budget Вичерпано

Найчастіша причина - вичерпано budget (ліміт розміру) для покоління:

void AllocateTillGC()
{
    int collectionsBefore = GC.CollectionCount(0);

    // Продовжуємо алокувати, поки не спрацює Gen0 GC
    while (GC.CollectionCount(0) == collectionsBefore)
    {
        var obj = new byte[1024];  // 1 KB
        // Gen0 budget зменшується...
    }

    Console.WriteLine("Gen0 GC triggered!");
}

Як CLR визначає budget:

  • Gen0: Залежить від heap size та історії GC
  • Динамічно адаптується (якщо багато garbage - збільшує budget)
  • Зазвичай 256 KB - 4 MB для Gen0

2. Явний Виклик GC.Collect()

// ❌ ПОГАНА практика у більшості випадків
GC.Collect();

// Збір конкретного покоління
GC.Collect(0);  // Тільки Gen0
GC.Collect(1);  // Gen0 + Gen1
GC.Collect(2);  // Full GC (Gen0 + Gen1 + Gen2)

// З додатковими параметрами
GC.Collect(2,
    GCCollectionMode.Optimized,  // Оптимальний час для збору
    blocking: true,               // Чекати завершення
    compacting: true);            // З компактуванням
Коли НЕ викликати GC.Collect():
  • ❌ У production коді "на всякий випадок"
  • ❌ Після кожної операції з пам'яттю
  • ❌ Для "звільнення пам'яті" без реальної потреби
  • ❌ У performance-critical секціях
Коли можна:
  • ✅ Після завантаження великої кількості даних, які більше не потрібні
  • ✅ У tests для детермінованої поведінки
  • ✅ У batch процесах між етапами обробки
  • ✅ Після ініціалізації додатку (разом з Gen2 compacting)

3. Low Memory Notification (Нестача Пам'яті)

ОС повідомляє CLR про низький рівень доступної пам'яті:

// Симуляція: реєстрація на memory pressure
AppDomain.CurrentDomain.MonitoringIsEnabled = true;

void MonitorMemory()
{
    long before = GC.GetTotalMemory(forceFullCollection: false);

    // При низькому рівні пам'яті ОС повідомить CLR
    // CLR може викликати aggressive Gen2 GC

    long after = GC.GetTotalMemory(forceFullCollection: false);

    Console.WriteLine($"Memory: {before / 1024 / 1024} MB -> {after / 1024 / 1024} MB");
}

CLR отримує notifications через:

  • Windows: CreateMemoryResourceNotification / QueryMemoryResourceNotification
  • Linux: memory cgroups, /proc/meminfo

4. No GC Region (Спеціальний Режим)

Можна запросити CLR не викликати GC протягом критичної секції:

bool TryNoGCRegion()
{
    long totalMemory = 10 * 1024 * 1024;  // 10 MB budget

    try
    {
        // Запитуємо CLR: "Не викликай GC, поки є 10 MB"
        if (GC.TryStartNoGCRegion(totalMemory))
        {
            // Критична секція - GC не спрацює (якщо вистачить budget)
            PerformLatencySensitiveOperation();

            return true;
        }

        return false;
    }
    finally
    {
        if (GC.TryStartNoGCRegion(0))  // Перевірка, чи в no-gc режимі
            GC.EndNoGCRegion();
    }
}

void PerformLatencySensitiveOperation()
{
    // Наприклад: real-time audio processing, high-frequency trading
    for (int i = 0; i < 1000; i++)
    {
        ProcessFrame();  // Немає GC паузи!
    }
}
Обмеження No-GC Region:
  • Якщо budget вичерпано, CLR кине InvalidOperationException
  • Працює тільки для короткочасних операцій
  • Не підходить для довгих processes
  • Потребує accurate estimation бюджету пам'яті

GC API та Моніторинг

Основні Методи GC Класу

GC.Collect()
void
имусовий збір сміття. Параметри: generation (0-2), mode, blocking, compacting.
GC.GetGeneration(object)
int
вертає покоління об'єкта (0, 1, або 2).
GC.GetTotalMemory(bool)
long
вертає приблизну кількість байт у managed heap. Якщо forceFullCollection=true, виконується Full GC перед підрахунком.
GC.CollectionCount(int)
int
вертає кількість збірок для вказаного покоління з моменту запуску процесу.
GC.WaitForPendingFinalizers()
void
ікує завершення всіх finalizers у черзі. Використовується після GC.Collect() для гарантії, що finalizers виконались.
GC.SuppressFinalize(object)
void
азує GC, що об'єкт не потребує фіналізації. Використовується у Dispose pattern.
GC.ReRegisterForFinalize(object)
void
вторно реєструє об'єкт для фіналізації (рідко використовується).
GC.KeepAlive(object)
void
рантує, що об'єкт залишається досяжним до цього моменту в коді. Запобігає передчасному збору.
GC.AddMemoryPressure(long)
void
відомляє GC про додаткове використання некерованої пам'яті. Збільшує ймовірність збору.
GC.AddMemoryPressure(long)
void
відомляє GC про додаткове використання некерованої пам'яті. Збільшує ймовірність збору.
GC.RemoveMemoryPressure(long)
void
відомляє GC про звільнення некерованої пам'яті.

Приклад моніторингу:

class GCMonitor
{
    static void Main()
    {
        Console.WriteLine("=== GC Statistics ===\n");

        // Загальна пам'ять
        long totalMemory = GC.GetTotalMemory(forceFullCollection: false);
        Console.WriteLine($"Total Memory: {totalMemory / 1024 / 1024} MB");

        // Кількість збірок по поколіннях
        Console.WriteLine($"Gen0 Collections: {GC.CollectionCount(0)}");
        Console.WriteLine($"Gen1 Collections: {GC.CollectionCount(1)}");
        Console.WriteLine($"Gen2 Collections: {GC.CollectionCount(2)}");

        // Створюємо об'єкт і відслідковуємо його покоління
        var obj = new object();
        Console.WriteLine($"\nObject generation: {GC.GetGeneration(obj)}");

        GC.Collect();
        Console.WriteLine($"After GC(0): {GC.GetGeneration(obj)}");

        GC.Collect();
        Console.WriteLine($"After GC(1): {GC.GetGeneration(obj)}");

        // GC Settings
        Console.WriteLine($"\n=== GC Configuration ===");
        Console.WriteLine($"GC Mode: {(GCSettings.IsServerGC ? "Server" : "Workstation")}");
        Console.WriteLine($"Latency Mode: {GCSettings.LatencyMode}");
        Console.WriteLine($"LOH Compaction: {GCSettings.LargeObjectHeapCompactionMode}");

        // GC Max Generation
        Console.WriteLine($"Max Generation: {GC.MaxGeneration}");  // Зазвичай 2
    }
}

Output:

=== GC Statistics ===

Total Memory: 2 MB
Gen0 Collections: 0
Gen1 Collections: 0
Gen2 Collections: 0

Object generation: 0
After GC(0): 1
After GC(1): 2

=== GC Configuration ===
GC Mode: Workstation
Latency Mode: Interactive
LOH Compaction: Default
Max Generation: 2

Некеровані Ресурси та Управління Ними

Що таке Unmanaged Resources?

Unmanaged resources (некеровані ресурси) - це ресурси операційної системи, які не управляються Garbage Collector:

File Handles
System.IO.FileStream
Дескриптори файлів, відкритих для читання/запису.
Network Connections
System.Net.Sockets.Socket
TCP/UDP сокети, HTTP connections.
Database Connections
System.Data.SqlClient.SqlConnection
З'єднання з базами дами.
Native Memory
IntPtr, void\*
Пам'ять, виділена через Marshal.AllocHGlobal(), malloc() в C/C++.
Window Handles (GDI Objects)
IntPtr
Вікна, brushes, pens, fonts в Windows API.
COM Objects
System.Runtime.InteropServices.ComTypes
Component Object Model об'єкти для interop.
Mutexes, Semaphores
System.Threading
Системні примітиви синхронізації.

Чому GC Не Може Їх Очистити?

GC відповідає тільки за managed heap. Він не знає про:

  • Скільки пам'яті займає відкритий файл в kernel space
  • Чи потрібно викликати close() для сокета
  • Як звільнити native memory pointer
class ProblematicCode
{
    void OpenFile()
    {
        FileStream fs = new FileStream("data.txt", FileMode.Open);
        byte[] buffer = new byte[1024];
        fs.Read(buffer, 0, buffer.Length);

        // ❌ ПОМИЛКА: Не закрили файл!
        // fs.Dispose() не викликано

    } // fs вийшов з scope, але FILE HANDLE ще відкритий!
      // GC зібере FileStream об'єкт, але handle залишиться!
}

Наслідки: File handle leak → через якийсь час ОС заборонить відкривати нові файли.

Windows: За замовчуванням процес може мати максимум ~10,000 handles. Якщо не закривати - досягнете ліміту та отримаєте IOException: Too many open files.Linux: Ліміт можна перевірити: ulimit -n. Зазвичай 1024-65535.

IDisposable Pattern: Правильне Управління Ресурсами

Інтерфейс IDisposable

IDisposable - стандартний механізм .NET для явного звільнення ресурсів:

public interface IDisposable
{
    void Dispose();
}

Контракт:

  • Метод Dispose() має звільнити всі некеровані ресурси
  • Після виклику Dispose() об'єкт стає unusable (не можна використовувати)
  • Можна викликати Dispose() кілька разів безпечно (idempotent)
  • Dispose() не викликається автоматично - відповідальність користувача класу

Базова Реалізація

Найпростіший випадок - клас з одним unmanaged resource:

class FileLogger : IDisposable
{
    private FileStream _fileStream;

    public FileLogger(string path)
    {
        _fileStream = new FileStream(path, FileMode.Append);
    }

    public void Dispose()
    {
        // Звільнити unmanaged resource
        _fileStream?.Dispose();
        _fileStream = null;
    }

    public void Log(string message)
    {
        if (_fileStream == null)
            throw new ObjectDisposedException(nameof(FileLogger));

        byte[] data = Encoding.UTF8.GetBytes(message + Environment.NewLine);
        _fileStream.Write(data, 0, data.Length);
    }
}

// Використання
using (var logger = new FileLogger("app.log"))
{
    logger.Log("Application started");
    logger.Log("Processing data");
} // Dispose() викликається автоматично
Null-Conditional Operator: _fileStream?.Dispose() безпечно викликає Dispose навіть якщо _fileStream вже null. Це забезпечує idempotency.

Повний Dispose Pattern (Full Pattern)

Для складніших сценаріїв (наслідування, комбінація managed + unmanaged resources) використовується повний dispose pattern:

class ResourceManager : IDisposable
{
    // Unmanaged resource
    private IntPtr _nativePtr;

    // Managed resource (також IDisposable)
    private FileStream _fileStream;

    // Flag для відстеження disposed стану
    private bool _disposed = false;

    public ResourceManager(string filePath)
    {
        // Алокуємо unmanaged memory
        _nativePtr = Marshal.AllocHGlobal(1024);

        // Відкриваємо файл
        _fileStream = new FileStream(filePath, FileMode.Create);
    }

    // Public Dispose method
    public void Dispose()
    {
        // Викликаємо protected Dispose з disposing=true
        Dispose(disposing: true);

        // Вказуємо GC: не викликати finalizer
        GC.SuppressFinalize(this);
    }

    // Protected virtual Dispose method
    protected virtual void Dispose(bool disposing)
    {
        // Перевірка: чи вже disposed?
        if (_disposed)
            return;

        if (disposing)
        {
            // Звільняємо MANAGED resources
            _fileStream?.Dispose();
        }

        // Звільняємо UNMANAGED resources (завжди!)
        if (_nativePtr != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(_nativePtr);
            _nativePtr = IntPtr.Zero;
        }

        _disposed = true;
    }

    // Destructor (Finalizer)
    ~ResourceManager()
    {
        // Викликається GC, якщо Dispose() не був викликаний
        // disposing=false -> тільки unmanaged cleanup
        Dispose(disposing: false);
    }

    // Helper method для перевірки disposed стану
    private void ThrowIfDisposed()
    {
        if (_disposed)
            throw new ObjectDisposedException(GetType().Name);
    }

    public void WriteData(byte[] data)
    {
        ThrowIfDisposed();
        _fileStream.Write(data, 0, data.Length);
    }
}

Розбір ключових моментів:

Dispose(bool disposing) - Серце Pattern

Параметр disposing:

  • true: викликано з Dispose() (managed context) → очищаємо і managed, і unmanaged
  • false: викликано з finalizer (недетерміновано) → очищаємо ТІЛЬКИ unmanaged

Чому? Під час finalization managed об'єкти можуть вже бути зібрані GC!

GC.SuppressFinalize(this)

Якщо Dispose() викликано явно, finalizer не потрібен → економимо ресурси GC.

_disposed Flag

Захист від повторного виклику. Без цього можна двічі звільнити ресурс → crash!

Finalizer ~ResourceManager()

Fallback на випадок, якщо користувач забув викликати Dispose(). Не ідеально, але краще ніж leak.

Loading diagram...
graph TD
    A[Object Created] --> B{User Called Dispose?}
    B -->|Yes| C[Dispose disposing=true]
    B -->|No| D[Object Unreachable]

    C --> E[Release Managed Resources]
    E --> F[Release Unmanaged Resources]
    F --> G[GC.SuppressFinalize]
    G --> H[Object Fully Disposed]

    D --> I[GC Finalizer Queue]
    I --> J[Finalizer ~Class]
    J --> K[Dispose disposing=false]
    K --> L[Release ONLY Unmanaged]
    L --> H

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style K fill:#f59e0b,stroke:#b45309,color:#ffffff
    style H fill:#64748b,stroke:#334155,color:#ffffff

Dispose Pattern з Наслідуванням

Якщо клас успадковує інший disposable клас:

class BaseResource : IDisposable
{
    private IntPtr _baseHandle;
    protected bool _disposed = false;

    public BaseResource()
    {
        _baseHandle = Marshal.AllocHGlobal(512);
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    // VIRTUAL - дозволяємо override в derived
    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
            return;

        if (disposing)
        {
            // Managed resources (якщо є)
        }

        // Unmanaged cleanup
        if (_baseHandle != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(_baseHandle);
            _baseHandle = IntPtr.Zero;
        }

        _disposed = true;
    }

    ~BaseResource()
    {
        Dispose(disposing: false);
    }
}
Best Practice для Наслідування:
  • Base class: Dispose method virtual
  • Derived class: override Dispose, викликати base.Dispose(disposing) в кінці
  • Finalizer потрібен тільки в base class
  • _disposed flag можна зробити protected для доступу в derived

IAsyncDisposable (.NET Standard 2.1+, C# 8.0+)

Для асинхронних ресурсів (наприклад, network streams, async file I/O):

class AsyncResourceManager : IAsyncDisposable
{
    private NetworkStream _networkStream;
    private SemaphoreSlim _semaphore = new SemaphoreSlim(1);
    private bool _disposed = false;

    public async ValueTask DisposeAsync()
    {
        if (_disposed)
            return;

        await DisposeAsyncCore().ConfigureAwait(false);

        // Cleanup synchronous resources
        Dispose(disposing: false);

        GC.SuppressFinalize(this);
        _disposed = true;
    }

    protected virtual async ValueTask DisposeAsyncCore()
    {
        // Асинхронне очищення
        if (_networkStream != null)
        {
            await _networkStream.FlushAsync().ConfigureAwait(false);
            await _networkStream.DisposeAsync().ConfigureAwait(false);
        }

        _semaphore?.Dispose();
    }

    protected virtual void Dispose(bool disposing)
    {
        // Синхронні unmanaged resources (якщо є)
    }
}

// Використання з await using
async Task ProcessAsync()
{
    await using var manager = new AsyncResourceManager();

    // Асинхронна робота
    await manager.ProcessDataAsync();

} // DisposeAsync() викликається автоматично

Коли використовувати IAsyncDisposable:

  • Network streams з асинхронним flush
  • Database connections з async cleanup
  • File streams з async flush (особливо для великих буферів)
  • Resources, де cleanup може блокувати thread
IAsyncDisposable + IDisposable: Можна реалізувати обидва інтерфейси. D ispose() буде fallback для synchronous context, DisposeAsync() - для async.
class DualDisposable : IDisposable, IAsyncDisposable
{
    public void Dispose() { /* sync path */ }
    public ValueTask DisposeAsync() { /* async path */ }
}

using Statement: Гарантований Dispose

Традиційний using Statement

using statement забезпечує автоматичний виклик Dispose навіть при exceptions:

using (FileStream fs = new FileStream("data.txt", FileMode.Open))
{
    // Робота з файлом
    byte[] buffer = new byte[1024];
    fs.Read(buffer, 0, buffer.Length);

    // Навіть якщо exception -> Dispose() викликається!
}
// fs.Dispose() гарантовано викликано

Компіляція в IL (спрощено):

FileStream fs = new FileStream("data.txt", FileMode.Open);
try
{
    byte[] buffer = new byte[1024];
    fs.Read(buffer, 0, buffer.Length);
}
finally
{
    if (fs != null)
        ((IDisposable)fs).Dispose();
}
Loading diagram...
sequenceDiagram
    participant Code as Application Code
    participant Res as IDisposable Resource
    participant GC as Garbage Collector

    Code->>Res: using (var res = new Resource())
    Note over Code,Res: Try block begins

    Code->>Res: Use resource
    Res-->>Code: Data/Operations

    alt Exception Occurs
        Code->>Code: Exception thrown
        Note over Code,Res: Finally block executes
        Code->>Res: Dispose()
        Res->>Res: Cleanup resources
        Code->>Code: Re-throw exception
    else Normal Execution
        Note over Code,Res: End of using scope
        Note over Code,Res: Finally block executes
        Code->>Res: Dispose()
        Res->>Res: Cleanup resources
    end

    Res->>GC: SuppressFinalize (if pattern used)
    Note over Res,GC: Object becomes garbage

    style Code fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style Res fill:#f59e0b,stroke:#b45309,color:#ffffff

using Declaration (C# 8.0+)

Scope-based disposal без фігурних дужок:

void ProcessFile()
{
    using FileStream fs = new FileStream("data.txt", FileMode.Open);
    using StreamReader reader = new StreamReader(fs);

    // Використання fs та reader
    string content = reader.ReadToEnd();
    Console.WriteLine(content);

    // Dispose викликається при виході з методу (в ЗВОРОТНОМУ порядку):
    // 1. reader.Dispose()
    // 2. fs.Dispose()
}

Порівняння:

void TraditionalUsing()
{
    using (var resource1 = new Resource())
    {
        using (var resource2 = new Resource())
        {
            using (var resource3 = new Resource())
            {
                // Використання
                DoWork();
            }
        }
    }

    // Вкладені блоки -> "Pyramid of Doom"
}
Коли використовувати using declaration:
  • Ресурс потрібен до кінця методу
  • Кілька ресурсів одночасно (уникає nesting)
  • Покращує читабельність
Коли traditional using:
  • Ресурс потрібен тільки для частини методу
  • Явний контроль над disposal scope

await using (Async Disposal)

Для IAsyncDisposable:

async Task ProcessDataAsync()
{
    // Traditional await using
    await using (var stream = new NetworkStream(...))
    {
        await stream.WriteAsync(data);
        await stream.FlushAsync();
    } // await stream.DisposeAsync()

    // await using declaration
    await using var connection = new AsyncDatabaseConnection();
    await connection.ExecuteQueryAsync("SELECT * FROM Users");

} // await connection.DisposeAsync()

Компіляція:

// await using (var res = new AsyncResource())
var res = new AsyncResource();
try
{
    // Use resource
}
finally
{
    if (res != null)
        await res.DisposeAsync().ConfigureAwait(false);
}

Multiple Resources в using

Кілька ресурсів одного типу:

// Традиційний синтаксис (рідко використовується)
using (FileStream input = File.OpenRead("input.txt"),
       output = File.OpenWrite("output.txt"))
{
    input.CopyTo(output);
}

// Краще: using declarations
void CopyFile()
{
    using var input = File.OpenRead("input.txt");
    using var output = File.OpenWrite("output.txt");

    input.CopyTo(output);
}

Різні типи:

async Task ProcessWithMultipleResources()
{
    using var connection = new SqlConnection(connectionString);
    await using var transaction = await connection.BeginTransactionAsync();
    using var semaphore = new SemaphoreSlim(1);

    await semaphore.WaitAsync();
    try
    {
        // Робота з resources
        await ExecuteInTransactionAsync(connection, transaction);
        await transaction.CommitAsync();
    }
    finally
    {
        semaphore.Release();
    }

    // Dispose order (reverse):
    // 1. semaphore.Dispose()
    // 2. await transaction.DisposeAsync()
    // 3. connection.Dispose()
}
Важливо: using declaration викликає Dispose у зворотному порядку оголошення. Це критично для залежних ресурсів (наприклад, transaction залежить від connection).

Finalizers (Деструктори): Safety Net з Обмеженнями

Синтаксис та Семантика

Finalizer (деструктор) - це спеціальний метод, який викликається GC перед звільненням пам'яті об'єкта. Детальний розбір механізму:

Базовий Приклад

class ResourceHolder
{
    private IntPtr _handle;

    public ResourceHolder()
    {
        _handle = AllocateResource();
    }

    // Finalizer (деструктор)
    ~ResourceHolder()
    {
        // Cleanup код
        if (_handle != IntPtr.Zero)
        {
            FreeResource(_handle);
            _handle = IntPtr.Zero;
        }
    }
}

Під капотом компілятор перетворює ~ClassName() у метод Finalize():

// Що генерує компілятор:
protected override void Finalize()
{
    try
    {
        // Ваш cleanup код тут
        if (_handle != IntPtr.Zero)
        {
            FreeResource(_handle);
            _handle = IntPtr.Zero;
        }
    }
    finally
    {
        base.Finalize();  // Автоматичний виклик finalizer базового класу
    }
}

Механізм Finalization

GC використовує дві спеціальні черги для управління об'єктами з finalizers. Процес складний і цікавий - розберемо детально.

Проблема: Об'єкти з finalizers живуть мінімум 2 GC cycles, що затримує звільнення пам'яті.

Performance Impact: Якщо ваш додаток створює багато об'єктів з finalizers, це може значно уповільнити GC та збільшити memory pressure, оскільки об'єкти залишаються у пам'яті довше.

WeakReference: Кешування без Memory Leaks

Основи WeakReference<T>

WeakReference дозволяє посилатися на об'єкт без запобігання його збору GC - ідеально для кешів.

class ImageCache
{
    private Dictionary<string, WeakReference<Image>> _cache = new();

    public Image GetImage(string path)
    {
        if (_cache.TryGetValue(path, out var weakRef) &&
            weakRef.TryGetTarget(out var image))
        {
            return image;  // Cache hit
        }

        // Cache miss - завантажити
        var newImage = Image.Load(path);
        _cache[path] = new WeakReference<Image>(newImage);
        return newImage;
    }
}

Memory Layout: Детальна Структура

Object Header у 32/64-bit

Кожен об'єкт має overhead:

64-bit процес:
┌──────────────┬──────────────┬─────────────┐
│ Sync Block 8B│MethodTable 8B│ Fields...   │
└──────────────┴──────────────┴─────────────┘
Мінімум: 24 байти для порожнього об'єкта

32-bit процес:
┌──────────────┬──────────────┬─────────────┐
│ Sync Block 4B│MethodTable 4B│ Fields...   │
└──────────────┴──────────────┴─────────────┘
Мінімум: 12 байт

Field Padding Приклад

struct BadLayout
{
    byte b1;    // 1 byte
    long l1;    // 8 bytes
    byte b2;    // 1 byte
}
// Actual size: 24 bytes (з padding!)

struct GoodLayout
{
    long l1;    // 8 bytes
    byte b1;    // 1 byte
    byte b2;    // 1 byte
}
// Actual size: 16 bytes
Оптимізація: Розташовуйте поля від найбільших до найменших для мінімізації padding та покращення cache locality.

Best Practices та Висновки

Ключові Правила

Golden Rules для Memory Management:
  1. Довіряйте GC - не викликайте GC.Collect() без вагомої причини
  2. Завжди Dispose - використовуйте using для всіх IDisposable ресурсів
  3. Уникайте Finalizers - використовуйте SafeHandle або IDisposable
  4. Профілюйте - вимірюйте memory usage, не здогадуйтесь
  5. Оптимізуйте Layout - порядок полів впливає на розмір та performance

Коли НЕ Викликати GC.Collect()

// ❌ ПОГАНО - після кожної операції
void ProcessItems()
{
    foreach (var item in items)
    {
        ProcessItem(item);
        GC.Collect();  // Катастрофа для performance!
    }
}

// ✅ ДОБРЕ - довіряйте GC
void ProcessItems()
{
    foreach (var item in items)
    {
        ProcessItem(item);
    }
    // GC сам вирішить, коли збирати
}

Винятки (коли можна викликати):

  • Unit tests для детермінованої поведінки
  • Після initialization з багатьма temp об'єктами
  • У batch processes між великими етапами
  • При вимірюванні memory usage

Практичні Завдання

Beginner

Завдання 1: Implement Dispose Pattern

Створіть клас FileLogger, який:

  • Використовує FileStream (managed resource)
  • Реалізує повний Dispose Pattern
  • Має finalizer як safety net
  • Кидає ObjectDisposedException після dispose

Очікуваний результат:

using (var logger = new FileLogger("app.log"))
{
    logger.Write("Test");
} // Dispose викликається автоматично

Intermediate

Завдання 2: WeakReference Cache

Реалізуйте thread-safe cache для images:

  • Використо вуйте WeakReference<Bitmap>
  • Додайте metrics (hits/misses)
  • Cleanup метод для dead entries
  • Протестуйте під memory pressure

Advanced

Завдання 3: Memory Layout Analyzer

Створіть utility, який аналізує структури:

  • Обчислює actual size з padding
  • Показує memory layout візуально
  • Пропонує оптимізований порядок полів
  • Використовує Marshal.SizeOf() та Unsafe.SizeOf()

Expert

Завдання 4: GC Profiler

Побудуйте GC monitoring tool:

  • Відслідковує Gen0/1/2 collections
  • Вимірює GC паузи
  • Визначає hot allocation paths
  • Генерує звіт з рекомендаціями

Резюме

Що Ви Вивчили

Managed Heap - швидка алокація через pointer bumping
GC Phases - marking, compaction, reference updating
Generations - Gen0/1/2 оптимізація на основі object lifetime
LOH/SOH/POH - різні heaps для різних сценаріїв (>85KB, pinned objects)
GC Modes - Workstation (low latency) vs Server (high throughput)
Background GC - concurrent збір для зменшення пауз
IDisposable Pattern - правильне управління unmanaged resources
using Statement - автоматичний dispose з гарантією cleanup
Finalizers - safety net з затримкою звільнення пам'яті
WeakReference - кешування без memory leaks
Memory Layout - object header, padding, alignment оптимізація

Найважливіші Концепції

КонцепціяКлюч до Розуміння
Generational GCБільшість об'єктів живуть коротко → Gen0 збирається часто
Dispose PatternDispose(bool) - managed cleanup якщо true, unmanaged завжди
LOH FragmentationОб'єкти ≥85KB не компактуються → можливі gaps
WeakReferenceПосилання не запобігає GC → ideal для caches
Memory OverheadМінімум 12-24 байти на об'єкт (header)

Наступні кроки:

📖 Threading and Async Programming - Дізнайтесь про конкурентність
📖 .NET Performance Tips - Оптимізація додатків
🔧 PerfView - GC profiling tool
🔧 dotMemory - Memory profiler

Пам'ятайте: Memory management у .NET - це баланс між developer productivity (автоматичний GC) та performance (розуміння, як працює GC). Розуміння fundamentals дає вам можливість писати ефективний код без жертвування продуктивністю розробки.