02Эффективная работа с памятью
2.1 Модель памяти .NET
Структура процесса .NET
При запуске .NET-приложения операционная система выделяет процессу виртуальную память, которая делится на несколько ключевых областей:
┌─────────────────────────────────────────────────┐
│ Process Memory │
├─────────────────────────────────────────────────┤
│ Stack (per thread) │
│ ├── Локальные переменные │
│ ├── Параметры методов │
│ └── Адреса возврата │
├─────────────────────────────────────────────────┤
│ Managed Heap (GC Heap) │
│ ├── Gen 0 (Ephemeral Segment) │
│ ├── Gen 1 (Ephemeral Segment) │
│ ├── Gen 2 │
│ ├── LOH (Large Object Heap) │
│ └── POH (Pinned Object Heap, .NET 5+) │
├─────────────────────────────────────────────────┤
│ Unmanaged Heap │
│ ├── Native allocations │
│ └── P/Invoke memory │
├─────────────────────────────────────────────────┤
│ Code / JIT Compiled Code │
├─────────────────────────────────────────────────┤
│ Reserved / Free │
└─────────────────────────────────────────────────┘Stack (Стек потока)
Характеристики:
- Размер по умолчанию: 1 MB (64-bit), 256 KB (32-bit)
- Выделение/освобождение: O(1) — просто сдвиг указателя
- Автоматическая очистка при выходе из scope
- LIFO структура (Last In, First Out)
Что хранится на стеке:
- Локальные переменные value types
- Параметры методов (включая ссылки на reference types)
- Адреса возврата из методов
- Ссылки на объекты в куче (но не сами объекты!)
void Example()
{
int x = 42; // На стеке (value type)
string s = "hello"; // Ссылка 's' на стеке, объект "hello" в куче
var obj = new MyClass(); // Ссылка 'obj' на стеке, объект в куче
}Важно: Стек не управляется GC. Память освобождается автоматически при выходе из метода.
Managed Heap (GC Heap)
Управляемая куча — это область памяти, управляемая Garbage Collector. Все reference type объекты размещаются здесь.
Ключевые свойства:
- Выделение памяти: O(1) — просто инкремент указателя
- Очистка: управляется GC (non-deterministic)
- Фрагментация: минимальна благодаря compacting GC
Generations (Поколения)
GC использует generational hypothesis:
- Молодые объекты быстро умирают
- Старые объекты живут долго
Gen 0 (Поколение 0)
- Назначение: Кратковременные объекты
- Размер: ~16 MB (зависит от платформы)
- Частота коллекции: Очень высокая (каждые несколько ms)
- Скорость: Самая быстрая коллекция
Примеры объектов в Gen 0:
// Эти объекты почти всегда живут только в Gen 0
var temp = new StringBuilder();
var result = someString.Split(',');
using var stream = new MemoryStream();Gen 1 (Поколение 1)
- Назначение: Буфер между Gen 0 и Gen 2
- Размер: ~256 MB
- Частота коллекции: Средняя
Объекты попадают сюда, если пережили коллекцию Gen 0.
Gen 2 (Поколение 2)
- Назначение: Долгоживущие объекты
- Размер: Динамический (до доступной памяти)
- Частота коллекции: Редкая (может быть секунды/минуты)
- Стоимость: Самая дорогая коллекция (full GC)
Примеры объектов в Gen 2:
// Эти объекты обычно попадают в Gen 2
static readonly Dictionary<string, object> Cache = new();
static readonly HttpClient SharedClient = new();Переход между поколениями
Allocation → Gen 0 ──(survived GC)──→ Gen 1 ──(survived GC)──→ Gen 2
↑ │
│ │
└──────(survived Gen 1 GC)────────────────┘Правила перехода:
- Новый объект всегда выделяется в Gen 0
- Если объект пережил GC Gen 0 → переходит в Gen 1
- Если объект пережил GC Gen 1 → переходит в Gen 2
- Gen 2 объекты остаются там до финализации или завершения приложения
Large Object Heap (LOH)
Что такое LOH
LOH — это отдельная куча для больших объектов (>= 85,000 bytes по умолчанию).
Характеристики LOH:
- Не компактируется по умолчанию (до .NET 4.5.1)
- Коллекции LOH происходят только при Gen 2 collection
- Объекты сразу попадают в Gen 2
- Массивы
double[]>= 1000 элементов всегда в LOH (даже если < 85KB)
Пороговые значения
// Попадет в LOH (>= 85,000 bytes)
var largeArray = new byte[100_000];
// Попадет в LOH (double[] >= 1000 элементов)
var doubles = new double[1_000]; // 8,000 bytes, но все равно LOH
// Обычная куча
var smallArray = new byte[1_000];Проблемы LOH
Фрагментация:
LOH до фрагментации:
[Obj 100KB][Obj 50KB][Obj 200KB][Free 300KB]
После освобождения Obj 50KB:
[Obj 100KB][Free 50KB][Obj 200KB][Free 300KB]
Новый объект 60KB не может использовать Free 50KB:
[Obj 100KB][Free 50KB][Obj 200KB][Free 300KB][New 60KB]Решение:
// .NET 4.5.1+ - включение компактизации LOH
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
// Или через appsettings.json:
{
"runtimeOptions": {
"gcLargeObjectHeapCompaction": true
}
}Pinned Object Heap (POH)
Что такое POH
Pinned Object Heap (POH) — это отдельная куча, добавленная в .NET 5, которая предназначена исключительно для размещения закреплённых (pinned) объектов.
Проблема до .NET 5: Когда объект закрепляется в куче (например, с помощью ключевого слова fixed или класса GCHandle), Garbage Collector не может перемещать его во время фазы сжатия (compaction). Это приводило к фрагментации памяти в стандартных поколениях (Gen 0, Gen 1, Gen 2) и на LOH, мешая GC эффективно уплотнять кучу.
Решение с POH: Объекты, размещённые на POH, изначально считаются закреплёнными. GC никогда не перемещает их и не пытается сжать POH, поэтому POH управляется с помощью списка свободных блоков (free list), что исключает негативное влияние закреплённых объектов на другие поколения GC кучи.
Как выделить память на POH
Память на POH выделяется явно с помощью вызова:
// Выделение массива байт на POH
byte[] pinnedArray = GC.AllocateArray<byte>(1024, pinned: true);
// Выделение неинициализированного массива на POH (быстрее)
byte[] uninitializedPinnedArray = GC.AllocateUninitializedArray<byte>(1024, pinned: true);Ключевые преимущества:
- Отсутствие фрагментации: стандартные поколения кучи остаются свободными от неподвижных объектов, что улучшает эффективность сжатия.
- Меньше работы для GC: сборщику мусора не нужно отслеживать сложную логику перемещения вокруг закрепленных областей в SOH/LOH.
GC Algorithms
Workstation GC vs Server GC
| Характеристика | Workstation GC | Server GC |
|---|---|---|
| Потоки GC | 1 | 1 на логический процессор |
| Память на GC | Минимальная | ~16 MB на core |
| Latency | Ниже | Выше |
| Throughput | Ниже | Выше |
| Подходит для | Desktop, UI | Server, backend |
Конфигурация:
<!-- .csproj -->
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>Или через环境变量:
DOTNET_gcServer=1
DOTNET_gcConcurrent=1Concurrent GC
Принцип: GC работает параллельно с приложением, минимизируя паузы.
Timeline:
App: ████████████████████████████████████████
GC: ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░ = GC работает в фоновом потоке
█ = приложение работаетПреимущества:
- Минимальные паузы (< 50ms для Gen 0/1)
- Подходит для interactive приложений
Недостатки:
- Выше общее потребление памяти
- Может быть lower throughput
Background GC (.NET 4.0+)
Принцип: Gen 2 коллекции выполняются в фоновом потоке, пока приложение продолжает работать.
Background GC:
Gen 0/1: ████░░████░░████░░████ (foreground, быстрые)
Gen 2: ░░░░░░░░░░░░████████░░ (background, медленные)
Приложение работает во время Gen 2 GC!Режимы latency:
// Низкая задержка (по умолчанию)
GCSettings.LatencyMode = GCLatencyMode.Interactive;
// Минимальная задержка (для real-time)
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
// Sustained low latency (.NET 4.5+)
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
// Batch mode (максимальный throughput)
GCSettings.LatencyMode = GCLatencyMode.Batch;Value Types vs Reference Types
Где хранятся данные
Распространенное заблуждение: "Value types всегда на стеке"
Реальность:
class MyClass
{
public int ValueField; // На куче (часть объекта MyClass)
}
void Method()
{
int localValue = 42; // На стеке
var obj = new MyClass(); // obj.ValueField на куче!
int[] arr = new int[10]; // Все элементы на куче!
}Правило:
- Если value type — часть reference type объекта → на куче
- Если value type — локальная переменная → на стеке
- Если value type — элемент массива → на куче
Boxing/Unboxing
Boxing — преобразование value type в reference type (object/interface).
// Boxing (аллокация в куче!)
int x = 42;
object boxed = x; // Создает новый объект в куче
// Unboxing (проверка типа + копирование)
int unboxed = (int)boxed;Стоимость boxing:
- Аллокация объекта в куче
- Копирование данных
- Давление на GC
Избежание boxing:
// ПЛОХО - boxing при каждом вызове
void Log(object value) => Console.WriteLine(value);
Log(42); // Boxing!
// ХОРОШО - generics без boxing
void Log<T>(T value) => Console.WriteLine(value);
Log(42); // No boxing!
// ХОРОШО - интерфейсы с struct
interface IProcessor { void Process(); }
struct MyProcessor : IProcessor { public void Process() { } }
IProcessor p = new MyProcessor(); // Boxing!
p.Process();
// Без boxing:
void Execute<T>(T processor) where T : IProcessor
{
processor.Process(); // No boxing!
}
Execute(new MyProcessor());Когда происходит boxing
- Приведение value type к
objectилиValueType - Приведение struct к interface
- Использование non-generic коллекций (
ArrayList,Hashtable) - String interpolation с value types (частично mitigated в новых версиях)
// Boxing примеры:
int i = 10;
object o = i; // Boxing
IComparable c = i; // Boxing
ArrayList list = new();
list.Add(i); // Boxing
// No boxing:
List<int> list2 = new();
list2.Add(i); // No boxing (generics)
var s = $"{i}"; // No boxing (span-based в .NET 8+)Ephemeral Segment
Что такое Ephemeral Segment
Ephemeral segment — это специальный участок памяти, содержащий Gen 0 и Gen 1.
Ephemeral Segment (~16-256 MB):
├── Gen 0 (активное выделение)
├── Gen 1 (пережившие Gen 0 GC)
└── Free spaceЗачем нужен:
- Быстрое выделение памяти (просто инкремент указателя)
- Быстрая коллекция Gen 0 (маленький участок памяти)
- Минимизация page faults
Когда заполняется Ephemeral Segment
Allocation → Gen 0 full → Gen 0 GC → Survivors → Gen 1
↓
Gen 1 full → Gen 1 GC → Survivors → Gen 2
↓
Ephemeral segment full → Allocate new segmentПрактика
Задание 1: Boxing/Unboxing Demo
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
class BoxingDemo
{
static void Main()
{
// Boxing через object
var sw = Stopwatch.StartNew();
for (int i = 0; i < 10_000_000; i++)
{
object boxed = i; // Boxing!
}
Console.WriteLine($"Boxing: {sw.ElapsedMilliseconds}ms");
// Без boxing (generics)
sw.Restart();
var list = new List<int>(10_000_000);
for (int i = 0; i < 10_000_000; i++)
{
list.Add(i); // No boxing
}
Console.WriteLine($"No boxing: {sw.ElapsedMilliseconds}ms");
}
}Задание 2: Benchmark Workstation vs Server GC
// Запуск с Workstation GC:
// dotnet run --configuration Release
// Запуск с Server GC:
// DOTNET_gcServer=1 dotnet run --configuration Release
using System;
using System.Diagnostics;
class GCBenchmark
{
static void Main()
{
Console.WriteLine($"Server GC: {GCSettings.IsServerGC}");
var sw = Stopwatch.StartNew();
long totalAllocated = 0;
for (int i = 0; i < 1000; i++)
{
var data = new byte[1_000_000]; // 1MB allocations
totalAllocated += data.Length;
if (i % 100 == 0)
{
Console.WriteLine($"Gen 0: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen 1: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen 2: {GC.CollectionCount(2)}");
}
}
Console.WriteLine($"Total: {sw.ElapsedMilliseconds}ms");
Console.WriteLine($"Allocated: {totalAllocated / 1_000_000}MB");
}
}Контрольные вопросы
- Почему
structв массиве хранится на куче, а не на стеке? - Что такое ephemeral segment и зачем он нужен?
- Когда происходит forced collection Gen 2?
- Какой размер объекта попадает в LOH по умолчанию?
- Может ли reference type храниться на стеке?
2.2 GC и управление жизненным циклом объектов
IDisposable Pattern
Зачем нужен IDisposable
IDisposable предоставляет детерминированный способ освобождения ресурсов, в отличие от GC, который работает недетерминированно.
Ресурсы, требующие IDisposable:
- File handles
- Database connections
- Network sockets
- Unmanaged memory
- GDI+ objects
- Mutex/Semaphore
Correct Implementation
Полный паттерн IDisposable:
public class ResourceHolder : IDisposable
{
// Unmanaged resource
private IntPtr _nativeHandle;
// Managed disposable resource
private FileStream? _fileStream;
// Track if already disposed
private bool _disposed;
public ResourceHolder(string path)
{
_fileStream = new FileStream(path, FileMode.Open);
_nativeHandle = AllocateNativeResource();
}
// Public IDisposable implementation
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // Предотвращаем вызов финализатора
}
// Protected virtual для наследования
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Освобождаем MANAGED ресурсы
_fileStream?.Dispose();
}
// Освобождаем UNMANAGED ресурсы
FreeNativeResource(_nativeHandle);
_nativeHandle = IntPtr.Zero;
_disposed = true;
}
}
// Финализатор — только если есть unmanaged ресурсы!
~ResourceHolder()
{
Dispose(false);
}
private static IntPtr AllocateNativeResource() => IntPtr.Zero; // placeholder
private static void FreeNativeResource(IntPtr handle) { } // placeholder
}Ключевые правила
GC.SuppressFinalize(this)— обязателен вDispose()
- Предотвращает помещение объекта в finalization queue
- Ускоряет GC (объект не требует финализации)
- Финализатор ТОЛЬКО для unmanaged ресурсов - Если нет unmanaged ресурсов — финализатор не нужен - Финализаторы замедляют GC (объект переживает минимум одну коллекцию)
Dispose(bool disposing)— protected virtual
- Позволяет наследникам правильно освобождать ресурсы
-
disposing == true → вызван из Dispose() → можно трогать managed объекты
- disposing == false → вызван из финализатора → managed объекты МОГУТ быть уже собраны
Modern IDisposable (.NET Core 3.0+)
public class ModernResource : IDisposable
{
private FileStream? _fileStream;
public void Dispose()
{
_fileStream?.Dispose();
GC.SuppressFinalize(this);
}
}
// Если нет unmanaged ресурсов — финализатор НЕ НУЖЕН!
// Если есть unmanaged — используйте SafeHandle вместо IntPtrNested Disposables
public class CompositeResource : IDisposable
{
private readonly IDisposable _resource1;
private readonly IDisposable _resource2;
private bool _disposed;
public CompositeResource(IDisposable r1, IDisposable r2)
{
_resource1 = r1;
_resource2 = r2;
}
public void Dispose()
{
if (_disposed) return;
// Dispose в обратном порядке создания
_resource2.Dispose();
_resource1.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}SafeHandle и CriticalFinalizerObject
SafeHandle
Проблема с IntPtr:
// ОПАСНО — race condition при ThreadAbort
class BadWrapper
{
private IntPtr _handle;
~BadWrapper()
{
CloseHandle(_handle); // Может не выполниться при ThreadAbort!
}
}Решение — SafeHandle:
public sealed class MySafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public MySafeHandle() : base(true) { }
protected override bool ReleaseHandle()
{
// Вызывается даже при ThreadAbort/OutOfMemory
return CloseHandle(handle);
}
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr handle);
}Преимущества SafeHandle:
- CriticalFinalizerObject гарантирует выполнение
- Reference counting предотвращает premature close
- Constrained Execution Region (CER)
CriticalFinalizerObject
// SafeHandle наследует от CriticalFinalizerObject
// Это гарантирует:
// 1. Финализатор выполнится даже при AppDomain unload
// 2. Финализатор выполнится при ThreadAbort
// 3. CLR подготовит код финализатора заранее (eager JIT)Finalizers
Когда использовать
Используйте финализатор ТОЛЬКО когда:
- Класс напрямую владеет unmanaged ресурсами
- Нет возможности использовать SafeHandle
НЕ используйте финализатор когда:
- Освобождаете только managed ресурсы
- Хотите "гарантировать" очистку (финализатор не гарантирован!)
Почему финализаторы — это плохо
class WithFinalizer
{
~WithFinalizer()
{
// Cleanup
}
}Проблемы:
- Двойная жизнь объекта: ``` Allocation → Gen 0 → GC → Finalization Queue → Gen 1 → Finalizer → Gen 2 ``` Объект с финализатором переживает минимум одну дополнительную коллекцию.
- Недетерминированность: - Неизвестно когда выполнится финализатор - Может не выполниться вообще (AppDomain crash)
- Порядок не гарантирован: - Если объект A ссылается на B, финализатор B может выполниться раньше A
- Производительность: - Все объекты с финализаторами попадают в finalization queue - Отдельный поток выполняет финализаторы - Full GC ждет завершения всех финализаторов
Best Practice
// ПЛОХО
class Bad
{
private FileStream _stream;
~Bad()
{
_stream.Dispose(); // Может быть уже null!
}
}
// ХОРОШО — без финализатора
class Good : IDisposable
{
private FileStream? _stream;
public void Dispose()
{
_stream?.Dispose();
GC.SuppressFinalize(this);
}
}
// ХОРОШО — с SafeHandle вместо финализатора
class Better : IDisposable
{
private MySafeHandle? _handle;
public void Dispose()
{
_handle?.Dispose();
GC.SuppressFinalize(this);
}
}WeakReference
Что такое WeakReference
WeakReference позволяет ссылаться на объект, не предотвращая его сборку GC.
var obj = new LargeObject();
var weakRef = new WeakReference<LargeObject>(obj);
// Проверяем, жив ли объект
if (weakRef.TryGetTarget(out var target))
{
// Объект жив, используем target
Console.WriteLine(target.Data);
}
else
{
// Объект собран GC
Console.WriteLine("Object was collected");
}
// Убираем сильную ссылку
obj = null;
GC.Collect();
GC.WaitForPendingFinalizers();
// Теперь TryGetTarget вернет falseКогда использовать
Кэширование:
public class ImageCache
{
private readonly Dictionary<string, WeakReference<Image>> _cache = new();
public Image GetOrLoad(string path)
{
if (_cache.TryGetValue(path, out var weakRef) &&
weakRef.TryGetTarget(out var image))
{
return image; // Cache hit
}
// Cache miss — загружаем
var newImage = LoadImage(path);
_cache[path] = new WeakReference<Image>(newImage);
return newImage;
}
}Event handler leak prevention:
public class Publisher
{
private readonly List<WeakReference<EventHandler>> _handlers = new();
public void Subscribe(EventHandler handler)
{
_handlers.Add(new WeakReference<EventHandler>(handler));
}
public void Publish(object sender, EventArgs e)
{
// Очищаем dead references
_handlers.RemoveAll(wr => !wr.TryGetTarget(out _));
foreach (var wr in _handlers)
{
if (wr.TryGetTarget(out var handler))
{
handler(sender, e);
}
}
}
}ConditionalWeakTable
Отличие от WeakReference
| Характеристика | Dictionary + WeakReference | ConditionalWeakTable |
|---|---|---|
| Key lifecycle | Key может быть собран | Key должен быть reference type |
| Value lifecycle | Value может быть собран | Value привязан к Key |
| Thread safety | Нет | Да |
| Enumeration | Да | Нет |
Использование
// Привязываем метаданные к объектам без изменения их класса
public static class ObjectMetadata
{
private static readonly ConditionalWeakTable<object, Metadata> _table = new();
public static void SetMetadata(object obj, Metadata metadata)
{
_table.AddOrUpdate(obj, metadata);
}
public static Metadata? GetMetadata(object obj)
{
return _table.TryGetValue(obj, out var metadata) ? metadata : null;
}
}
// Когда obj будет собран GC, его metadata тоже будет собрана!Практический пример — Extension Properties
public static class TagExtensions
{
private static readonly ConditionalWeakTable<object, Dictionary<string, object>> _tags = new();
public static void SetTag(this object obj, string key, object value)
{
var dict = _tags.GetValue(obj, _ => new Dictionary<string, object>());
dict[key] = value;
}
public static T? GetTag<T>(this object obj, string key)
{
if (_tags.TryGetValue(obj, out var dict) && dict.TryGetValue(key, out var value))
{
return (T)value;
}
return default;
}
}GC.Collect() — Когда допустимо вызывать
Почему почти никогда
// ПЛОХО — принудительная коллекция без причины
void ProcessData()
{
var data = LoadLargeData();
Process(data);
GC.Collect(); // Зачем? GC сам знает когда собирать!
}Проблемы:
- Нарушает оптимизации GC
- Может вызвать unnecessary Gen 2 collection
- Дублирует работу GC
Когда ДОПУСТИМО
- После загрузки больших данных:
void LoadConfiguration()
{
var tempData = LoadTempFiles(); // Много временных объектов
Process(tempData);
// tempData больше не нужен, можно подсказать GC
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true);
GC.WaitForPendingFinalizers();
}- В тестах:
[Test]
public void Test_NoMemoryLeak()
{
var weakRef = CreateObject();
// Принудительная коллекция для теста
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Assert.IsFalse(weakRef.IsAlive);
}- При переходе в LowLatency mode:
// Перед входом в critical section
GC.Collect(); // Очищаем кучу перед low latency period
GCSettings.LatencyMode = GCLatencyMode.LowLatency;- Server приложения при idle:
// В background task во время низкой нагрузки
async Task PeriodicCleanupAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(5), ct);
if (IsIdle())
{
GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized);
}
}
}GCCollectionMode
// Default — зависит от версии .NET
GC.Collect();
// Forced — выполнить немедленно
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
// Optimized — GC решает, оптимально ли сейчас собирать (.NET 4.6+)
GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized);
// Aggressive (.NET 10+) — агрессивная очистка
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive);Object Pool
Microsoft.Extensions.ObjectPool
using Microsoft.Extensions.ObjectPool;
// Создание pool
var policy = new StringBuilderPooledObjectPolicy();
var pool = new DefaultObjectPool<StringBuilder>(policy, 100);
// Использование
var sb = pool.Get();
try
{
sb.Append("Hello, ");
sb.Append("World!");
return sb.ToString();
}
finally
{
sb.Clear(); // Очищаем перед возвратом
pool.Return(sb);
}Custom Object Pool
public class PooledObject<T> : IDisposable where T : class, new()
{
private readonly ObjectPool<T> _pool;
public T Value { get; }
private int _disposed;
public PooledObject(ObjectPool<T> pool)
{
_pool = pool;
Value = pool.Get();
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
_pool.Return(Value);
}
}
}
// Использование
using var pooled = new PooledObject<MyExpensiveObject>(myPool);
pooled.Value.DoWork();
// Автоматически возвращается в pool при выходе из usingObject Pool с ConditionalWeakTable
public class ContextualObjectPool<T> where T : class, new()
{
private readonly Stack<T> _pool = new();
private readonly ConditionalWeakTable<T, object> _context = new();
private readonly object _lock = new();
public T Get()
{
lock (_lock)
{
return _pool.Count > 0 ? _pool.Pop() : new T();
}
}
public void Return(T obj, object contextData)
{
_context.AddOrUpdate(obj, contextData);
lock (_lock)
{
_pool.Push(obj);
}
}
public object? GetContext(T obj)
{
return _context.TryGetValue(obj, out var ctx) ? ctx : null;
}
}Leak Detector на WeakReference
public class MemoryLeakDetector
{
private readonly List<WeakReference> _trackedObjects = new();
private readonly List<string> _descriptions = new();
public void Track(object obj, string description)
{
_trackedObjects.Add(new WeakReference(obj));
_descriptions.Add(description);
}
public void Report()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine("=== Memory Leak Report ===");
for (int i = 0; i < _trackedObjects.Count; i++)
{
var isAlive = _trackedObjects[i].IsAlive;
var status = isAlive ? "LEAKED" : "Collected";
Console.WriteLine($"{status}: {_descriptions[i]}");
}
}
}
// Использование
void Test()
{
var detector = new MemoryLeakDetector();
var obj1 = new object();
var obj2 = new object();
detector.Track(obj1, "Temporary object");
detector.Track(obj2, "Cached object");
obj1 = null; // Должен быть собран
// obj2 все еще ссылается
detector.Report();
// LEAKED: Cached object
// Collected: Temporary object
}Практика
Задание 1: Правильный IDisposable Pattern
Реализуйте класс DatabaseConnection с:
- Managed ресурсом (SqlCommand)
- Unmanaged ресурсом (native handle через SafeHandle)
- Поддержкой наследования
- Nested disposables
Задание 2: Object Pool
Создайте ObjectPool<T> с:
- Максимальным размером пула
- Thread-safe операциями
- Поддержкой
ConditionalWeakTableдля метаданных - Автоматическим возвратом через
using
Задание 3: Leak Detector
Напишите утилиту для обнаружения memory leaks:
- Отслеживание объектов через
WeakReference - Автоматический report после GC
- Интеграция с unit tests
Контрольные вопросы
- Зачем нужен
GC.SuppressFinalize(this)вDispose()? - Почему финализаторы замедляют GC?
- Когда
Dispose(false)вызывается? - В чем разница между
WeakReferenceиConditionalWeakTable? - Можно ли использовать
GC.Collect()в production? - Зачем в .NET 5 был введен Pinned Object Heap (POH)?
2.3 Диагностика памяти
Обзор инструментов
┌─────────────────────────────────────────────────────────────┐
│ Memory Diagnostics │
├──────────────────┬──────────────────┬───────────────────────┤
│ Real-time │ Post-mortem │ Profiling │
│ (live metrics) │ (after crash) │ (detailed analysis) │
├──────────────────┼──────────────────┼───────────────────────┤
│ dotnet-counters │ dotnet-dump │ dotnet-gcdump │
│ EventCounter │ SOS commands │ Visual Studio │
│ Prometheus │ WinDbg │ JetBrains DotMemory │
│ ApplicationIns. │ crash dumps │ PerfView │
└──────────────────┴──────────────────┴───────────────────────┘dotnet-counters
Установка и базовое использование
# Установка
dotnet tool install -g dotnet-counters
# Список доступных процессов
dotnet-counters ps
# Мониторинг в реальном времени
dotnet-counters monitor -p <PID>
# Мониторинг конкретных счетчиков
dotnet-counters monitor -p <PID> --counters System.RuntimeКлючевые метрики памяти
| Счетчик | Описание | Нормальное значение |
|---|---|---|
working-set | Физическая память процесса | Зависит от приложения |
gc-heap-size | Размер GC кучи | Стабильный в steady state |
gen-0-gc-count | Количество Gen 0 коллекций | Растет пропорционально нагрузке |
gen-1-gc-count | Количество Gen 1 коллекций | Меньше Gen 0 |
gen-2-gc-count | Количество Gen 2 коллекций | Минимальное в steady state |
allocation-rate | Скорость аллокаций (bytes/sec) | < 1 MB/sec для оптимизированных |
gc-fragmentation | Фрагментация кучи | < 5% |
loh-size | Размер Large Object Heap | Стабильный |
Практическое использование
# Мониторинг с кастомными счетчиками
dotnet-counters monitor -p 12345 --counters "
System.Runtime,
Microsoft.AspNetCore.Hosting
"
# Сбор данных для последующего анализа
dotnet-counters collect -p 12345 --counters System.Runtime -o metrics.json
# Анализ collected данных
dotnet-counters report -i metrics.jsonEventCounter в коде
using System.Diagnostics.Metrics;
// Создание Meter
var meter = new Meter("MyApp.Memory", "1.0.0");
// Создание counters
var allocationCounter = meter.CreateCounter<long>(
"allocations.bytes",
unit: "byte",
description: "Total bytes allocated");
var poolSizeGauge = meter.CreateGauge<int>(
"pool.size",
unit: "{objects}",
description: "Current pool size");
// Использование
void ProcessData(byte[] data)
{
allocationCounter.Add(data.Length);
// ... processing
}Integration с Prometheus
using OpenTelemetry.Metrics;
using OpenTelemetry.Exporter.Prometheus;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter("MyApp.Memory");
metrics.AddPrometheusExporter();
});
var app = builder.Build();
app.MapPrometheusScrapingEndpoint();
app.Run();Prometheus query examples:
# Allocation rate за последние 5 минут
rate(myapp_memory_allocations_bytes_total[5m])
# GC collections per second
rate(system_runtime_gen_0_gc_count_total[1m])
# Heap size trend
system_runtime_gc_heap_sizedotnet-gcdump
Что такое GC Dump
GC Dump — это снимок managed heap в определенный момент времени. Содержит:
- Все объекты в куче
- Ссылки между объектами
- Размеры объектов
- Типы объектов
Сбор GC Dump
# Установка
dotnet tool install -g dotnet-gcdump
# Сбор dump
dotnet-gcdump collect -p <PID> -o memory.gcdump
# Сбор с verbose output
dotnet-gcdump collect -p <PID> -v -o memory.gcdumpАнализ GC Dump
Вариант 1: Visual Studio
- Откройте
.gcdumpфайл в Visual Studio - Перейдите во вкладку "Summary" для обзора
- Используйте "Dominators" для поиска корней утечек
- Сортируйте по "Retained Size" для поиска больших объектов
Вариант 2: Speedscope (online)
- Откройте https://www.speedscope.app
- Загрузите
.gcdumpфайл - Анализируйте дерево объектов
Вариант 3: JetBrains DotMemory
- Откройте DotMemory
- Import
.gcdumpфайл - Используйте сравнение snapshots для поиска утечек
Поиск memory leak
Шаги анализа:
1. Collect snapshot #1 (начальное состояние)
2. Выполнить операцию N раз
3. Collect snapshot #2
4. Сравнить snapshots
5. Найти типы с наибольшим ростом
6. Найти GC roots для этих объектовПример анализа:
Snapshot #1 → Snapshot #2 comparison:
Type | #1 Count | #2 Count | Delta
------------------------|----------|----------|-------
MyApp.EventSubscription | 100 | 10,100 | +10,000 ← LEAK!
System.String | 5,000 | 5,200 | +200
System.Byte[] | 200 | 210 | +10
GC Root для EventSubscription:
static field → EventManager._subscribers → List<EventSubscription>dotnet-dump
Сбор и анализ
# Установка
dotnet tool install -g dotnet-dump
# Сбор dump
dotnet-dump collect -p <PID> --type Full -o dump.dmp
# Анализ
dotnet-dump analyze dump.dmpОсновные SOS команды
# Общая информация о GC куче
eeheap -gc
# Статистика по типам объектов
dumpheap -stat
# Объекты конкретного типа
dumpheap -type System.String
# Детали конкретного объекта
dumpobj <address>
# Найти корни объекта (почему не собран?)
gcroot <address>
# Очередь финализации
finalizequeue
# Стек текущего потока
clrstack
# Все managed потоки
clrthreads
# Объекты на стеке
dso
# Async state machines
dumpasync
# Информация об исключениях
peПрактический пример анализа утечки
# 1. Смотрим общую картину
> eeheap -gc
GC Heap Size: 512 MB
# 2. Находим самые большие типы
> dumpheap -stat
MT Count TotalSize Class Name
...
00007ff8c0a12340 100000 256000000 MyApp.CachedData
00007ff8c0a56780 50000 128000000 System.Byte[]
# 3. Смотрим детали объекта
> dumpobj 000001a2b0001000
Name: MyApp.CachedData
MethodTable: 00007ff8c0a12340
Size: 2560 bytes
# 4. Находим корни
> gcroot 000001a2b0001000
Thread 1234:
000000b3c000f000 00007ff8c0a12340 MyApp.Program.Main()
-> 000001a2b0001000 MyApp.CachedData
# 5. Смотрим стек
> clrstack
OS Thread Id: 0x1234
Child SP IP Call Site
000000b3c000f000 00007ff8c0a12340 MyApp.Program.Main()PerfView
Установка
# Download from GitHub
# https://github.com/microsoft/perfview/releasesСбор данных
- Запустите PerfView
- File → Collect → Collect
- Включите "GC Only" для memory profiling
- Запустите приложение
- Выполните тестовый сценарий
- Stop Collection
Анализ
GC Stats:
- Откройте "GC Stats" в collected data
- Смотрите "GC Heap Alloc Stacks" для поиска hot paths
- Анализируйте "GC Events" для понимания коллекций
Memory Allocation Stacks:
GC Heap Alloc Stacks:
└── MyApp.Processor.HandleMessage()
└── new byte[1024] ← 45% allocations
└── string.Substring() ← 30% allocations
└── JsonConvert.Serialize() ← 25% allocationsVisual Studio Diagnostic Tools
Memory Usage Tool
- Debug → Start Diagnostic Tools Without Debugging (Alt+F2)
- Выберите "Memory Usage"
- Запустите приложение
- Take Snapshot в ключевых точках
- Сравните snapshots
Profiling
- Debug → Performance Profiler (Alt+F2)
- Выберите ".NET Object Allocation Tracking"
- Запустите profiling
- Анализируйте allocation tree
Интерпретация результатов
Memory Snapshot Comparison:
Handle Count | Size (bytes) | Type
-------------|--------------|------------------
+10,000 | +2,560,000 | MyApp.EventSubscription ← LEAK
+200 | +12,800 | System.String
+10 | +1,024 | System.Int32
Root Path для EventSubscription:
static MyApp.EventManager._handlers
→ Dictionary<string, List<EventHandler>>
→ List<EventHandler>
→ EventHandler.Target → MyApp.SubscriberПрактика
Задание 1: Найти и устранить memory leak
Сломанное приложение:
public class LeakyService
{
private static readonly List<object> _cache = new();
public void Process(object data)
{
// УТЕЧКА: объекты никогда не удаляются из cache
_cache.Add(new { Data = data, Timestamp = DateTime.UtcNow });
}
}
public class EventLeak
{
private readonly EventSource _source;
public EventLeak(EventSource source)
{
_source = source;
// УТЕЧКА: handler не отписывается
_source.OnEvent += HandleEvent;
}
private void HandleEvent(object sender, EventArgs e) { }
}Шаги диагностики:
- Запустите приложение
- Соберите baseline snapshot
- Выполните 10,000 операций
- Соберите второй snapshot
- Найдите типы с наибольшим ростом
- Найдите GC roots
- Исправьте код
Задание 2: Профилирование высоконагруженного сервиса
// Цель: оптимизировать allocation rate
public class MessageProcessor
{
public void Process(Stream input)
{
// ПЛОХО — много аллокаций
var buffer = new byte[input.Length];
input.Read(buffer, 0, buffer.Length);
var json = Encoding.UTF8.GetString(buffer);
var obj = JsonConvert.DeserializeObject(json);
var result = ProcessObject(obj);
var output = JsonConvert.SerializeObject(result);
return Encoding.UTF8.GetBytes(output);
}
}Оптимизация:
public class OptimizedMessageProcessor
{
private static readonly ArrayPool<byte> _pool = ArrayPool<byte>.Shared;
public void Process(Stream input)
{
// ХОРОШО — pooled buffers
var buffer = _pool.Rent((int)input.Length);
try
{
input.Read(buffer, 0, (int)input.Length);
// ... processing с Span<byte>
}
finally
{
_pool.Return(buffer);
}
}
}Задание 3: Dashboard с Prometheus
// Создайте middleware для memory metrics
public class MemoryMetricsMiddleware
{
private readonly RequestDelegate _next;
private static readonly Gauge _workingSet = Metrics.CreateGauge(
"process_working_set_bytes", "Process working set in bytes");
public MemoryMetricsMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
_workingSet.Set(Environment.WorkingSet);
await _next(context);
}
}
// Grafana dashboard panels:
// 1. Working Set (current memory usage)
// 2. GC Collections rate (gen 0/1/2 per second)
// 3. Allocation rate (bytes per second)
// 4. LOH size trend
// 5. GC Heap fragmentation %Контрольные вопросы
- Какой инструмент использовать для real-time мониторинга?
- Как найти причину memory leak?
- Собрать 2+ GC dump с интервалом
- Сравнить snapshots
- Найти типы с наибольшим ростом
- Использовать
gcrootдля поиска корней - Исправить код (убрать static cache, отписать events)
- Что показывает
dumpheap -stat? - Когда использовать
dotnet-dumpvsdotnet-gcdump? - Как интерпретировать
allocation-rate?
2.4 Zero-Allocation Patterns
Span — Deep Dive
Что такое Span
Span<T> — это ref struct, предоставляющий type-safe представление contiguous memory region без аллокаций.
// Span<T> может работать с:
// 1. Массивами
Span<int> fromArray = new int[] { 1, 2, 3 };
// 2. Стеком
Span<byte> fromStack = stackalloc byte[256];
// 3. Unmanaged памятью
unsafe
{
var ptr = Marshal.AllocHGlobal(100);
Span<byte> fromUnmanaged = new Span<byte>((void*)ptr, 100);
Marshal.FreeHGlobal(ptr);
}Ключевые свойства
public readonly ref struct Span<T>
{
public int Length { get; }
public ref T this[int index] { get; }
public bool IsEmpty { get; }
public ref T GetPinnableReference();
// Slicing — БЕЗ аллокаций!
public Span<T> Slice(int start);
public Span<T> Slice(int start, int length);
// Conversion
public Span<T> this[Range range] { get; }
}Slicing без аллокаций
// ПЛОХО — аллокация нового массива
string text = "Hello, World!";
string substring = text.Substring(7, 5); // "World" — NEW STRING
// ХОРОШО — zero allocation
string text = "Hello, World!";
ReadOnlySpan<char> span = text.AsSpan();
ReadOnlySpan<char> slice = span.Slice(7, 5); // "World" — NO ALLOCATIONParsing с Span
// ПЛОХО — много аллокаций
string ParseCsv(string line)
{
var parts = line.Split(','); // Allocates array + strings
return parts[1].Trim(); // Another allocation
}
// ХОРОШО — zero allocation
string ParseCsv(ReadOnlySpan<char> line)
{
int commaIndex = line.IndexOf(',');
var value = line.Slice(commaIndex + 1);
// Trim без аллокаций
int start = 0;
while (start < value.Length && char.IsWhiteSpace(value[start])) start++;
int end = value.Length;
while (end > start && char.IsWhiteSpace(value[end - 1])) end--;
return value.Slice(start, end - start).ToString();
}Span ограничения
// Span<T> — ref struct, НЕЛЬЗЯ:
// ❌ Использовать в полях класса
class Bad { Span<int> _field; } // CS8345
// ❌ Использовать в async методах
async Task BadAsync() { Span<int> s = stackalloc int[10]; } // CS8352
// ❌ Возвращать из метода (если stackalloc)
Span<int> BadReturn() { return stackalloc int[10]; } // CS8352
// ❌ Box в object
object o = span; // CS8346
// ❌ Использовать в интерфейсах
void Process<T>(T span) where T : ISpan<int> { } // Не работает
// ✅ МОЖНО использовать:
// - В локальных переменных
// - В параметрах методов
// - В ref return
// - В struct полях (если struct тоже ref struct)Memory
Когда использовать Memory
Memory<T> — это regular struct (не ref struct), можно использовать в async и полях класса.
// ✅ Memory<T> можно хранить в поле
class BufferManager
{
private Memory<byte> _buffer; // OK
public BufferManager(Memory<byte> buffer)
{
_buffer = buffer;
}
}
// ✅ Memory<T> можно использовать в async
async Task ProcessAsync(Memory<byte> buffer)
{
await Task.Delay(100);
buffer.Span[0] = 42; // OK
}
// Conversion
Memory<byte> memory = new byte[100];
Span<byte> span = memory.Span; // Get Span from MemoryReadOnlyMemory
// Immutable version — для чтения
public class MessageReader
{
private readonly ReadOnlyMemory<byte> _data;
public MessageReader(ReadOnlyMemory<byte> data)
{
_data = data;
}
public int ReadInt32(int offset)
{
return MemoryMarshal.Read<int>(_data.Span.Slice(offset));
}
}ArrayPool
Базовое использование
// Shared pool — для общего использования
var pool = ArrayPool<byte>.Shared;
// Rent buffer
byte[] buffer = pool.Rent(1024); // Returns at least 1024 bytes
try
{
// Use buffer
buffer[0] = 42;
}
finally
{
// ALWAYS return to pool!
pool.Return(buffer);
}Стратегии возврата
// Return с очисткой (безопасно, но медленнее)
pool.Return(buffer, clearArray: true);
// Return без очистки (быстрее, но данные остаются)
pool.Return(buffer, clearArray: false);
// Когда использовать clearArray:
// - true: если buffer содержал sensitive data
// - false: если вы перезаписываете все данные перед использованиемCustom ArrayPool
public class LimitedArrayPool<T> : ArrayPool<T>
{
private readonly int _maxArrays;
private readonly int _arraySize;
private readonly Queue<T[]> _pool;
private readonly object _lock = new();
public LimitedArrayPool(int maxArrays, int arraySize)
{
_maxArrays = maxArrays;
_arraySize = arraySize;
_pool = new Queue<T[]>(maxArrays);
}
public override T[] Rent(int minimumLength)
{
if (minimumLength > _arraySize)
return new T[minimumLength]; // Too big for pool
lock (_lock)
{
if (_pool.Count > 0)
return _pool.Dequeue();
}
return new T[_arraySize];
}
public override void Return(T[] array, bool clearArray = false)
{
if (array.Length != _arraySize)
return; // Not from this pool
if (clearArray)
Array.Clear(array);
lock (_lock)
{
if (_pool.Count < _maxArrays)
_pool.Enqueue(array);
}
}
}ArrayPool + Span паттерн
public static void ProcessWithPool(ReadOnlySpan<byte> input)
{
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(input.Length * 2);
try
{
Span<byte> span = buffer.AsSpan(0, input.Length * 2);
// Process with Span
input.CopyTo(span);
Transform(span);
// Use result
Output(span);
}
finally
{
pool.Return(buffer);
}
}stackalloc
Базовое использование
// Без unsafe (через Span<T>)
Span<byte> buffer = stackalloc byte[256];
Span<int> numbers = stackalloc int[100];
// С unsafe (прямые pointers)
unsafe
{
byte* ptr = stackalloc byte[256];
int* nums = stackalloc int[100];
}Ограничения
// ❌ Нельзя в цикле (stack overflow risk)
void Bad()
{
for (int i = 0; i < 1000; i++)
{
Span<byte> buf = stackalloc byte[1024]; // Stack overflow!
}
}
// ❌ Нельзя большой размер (limit ~1MB)
Span<byte> huge = stackalloc byte[2_000_000]; // Stack overflow!
// ❌ Нельзя variable size в некоторых случаях
Span<byte> dynamic = stackalloc byte[GetSize()]; // OK в .NET Core
// Но размер должен быть reasonable
// ✅ Правильное использование
void Good()
{
// Small, fixed-size buffers
Span<byte> header = stackalloc byte[64];
Span<int> indices = stackalloc int[32];
// Use buffer
ReadHeader(header);
ProcessIndices(indices);
} // Memory automatically reclaimedConditional stackalloc
// Паттерн: stackalloc для small, pool для large
public static void Process(ReadOnlySpan<byte> input)
{
Span<byte> buffer;
byte[]? arrayPoolBuffer = null;
if (input.Length <= 256)
{
buffer = stackalloc byte[256];
}
else
{
arrayPoolBuffer = ArrayPool<byte>.Shared.Rent(input.Length);
buffer = arrayPoolBuffer.AsSpan(0, input.Length);
}
try
{
input.CopyTo(buffer);
Transform(buffer);
Output(buffer);
}
finally
{
if (arrayPoolBuffer != null)
{
ArrayPool<byte>.Shared.Return(arrayPoolBuffer);
}
}
}ref returns, ref locals, in parameters
ref locals
// ref local — ссылка на существующую переменную
int x = 42;
ref int y = ref x;
y = 100; // x теперь тоже 100
// ref в массиве
int[] arr = { 1, 2, 3, 4, 5 };
ref int first = ref arr[0];
first = 10; // arr[0] = 10
// ref в Span
Span<int> span = stackalloc int[5];
ref int item = ref span[2];
item = 42; // span[2] = 42ref returns
// Возврат ссылки на элемент
public ref int Find(int[] array, Predicate<int> match)
{
for (int i = 0; i < array.Length; i++)
{
if (match(array[i]))
return ref array[i]; // ref return
}
throw new NotFoundException();
}
// Использование
int[] numbers = { 1, 2, 3, 4, 5 };
ref int found = ref Find(numbers, n => n == 3);
found = 30; // numbers[2] = 30in parameters
// in parameter — readonly ref (без копирования)
struct LargeStruct
{
public int A, B, C, D, E, F, G, H; // 32 bytes
}
// ПЛОХО — копирование при каждом вызове
void Process(LargeStruct s) { }
// ХОРОШО — передача по ссылке, readonly
void Process(in LargeStruct s) { }
// Вызов
var s = new LargeStruct { A = 1 };
Process(s); // No copy!
// in с Span
void ProcessData(in ReadOnlySpan<byte> data)
{
// data нельзя модифицировать
// но и не копируется
}ref struct
// ref struct — может содержать только ref types
public ref struct SpanEnumerator<T>
{
private readonly Span<T> _span;
private int _index;
public SpanEnumerator(Span<T> span)
{
_span = span;
_index = -1;
}
public bool MoveNext() => ++_index < _span.Length;
public ref T Current => ref _span[_index];
}
// Использование
ref struct Parser
{
private ReadOnlySpan<char> _input;
private int _position;
public Parser(ReadOnlySpan<char> input)
{
_input = input;
_position = 0;
}
public bool TryParseInt(out int value)
{
// Zero-allocation parsing
var span = _input.Slice(_position);
int end = span.IndexOfAny(' ', ',', ';');
if (end == -1) end = span.Length;
bool result = int.TryParse(span.Slice(0, end), out value);
_position += end;
return result;
}
}ReadOnlySequence
Что такое ReadOnlySequence
ReadOnlySequence<T> — представляет последовательность данных, которая может быть разбита на multiple segments (buffers).
// Создание из одного buffer
ReadOnlySequence<byte> single = new ReadOnlySequence<byte>(new byte[] { 1, 2, 3 });
// Создание из multiple buffers
var segment1 = new byte[] { 1, 2, 3 };
var segment2 = new byte[] { 4, 5, 6 };
var seq = new ReadOnlySequence<byte>(
new BufferSegment(segment1),
0,
new BufferSegment(segment2),
segment2.Length
);Stream processing
public static async Task ProcessStreamAsync(Stream stream)
{
var pipe = new Pipe();
// Copy stream to pipe
_ = stream.CopyToAsync(pipe.Writer.AsStream());
while (true)
{
ReadResult result = await pipe.Reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;
try
{
while (TryParseMessage(ref buffer, out var message))
{
ProcessMessage(message);
}
if (result.IsCompleted)
break;
}
finally
{
// Advance past consumed data
pipe.Reader.AdvanceTo(buffer.Start, buffer.End);
}
}
}
private static bool TryParseMessage(
ref ReadOnlySequence<byte> buffer,
out ReadOnlySequence<byte> message)
{
// Find message delimiter
SequencePosition? position = buffer.PositionOf((byte)'\n');
if (position == null)
{
message = default;
return false;
}
// Extract message
message = buffer.Slice(0, position.Value);
buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
return true;
}Практика
Задание 1: JSON Parser без аллокаций
public ref struct JsonParser
{
private ReadOnlySpan<char> _input;
private int _position;
public JsonParser(ReadOnlySpan<char> input)
{
_input = input;
_position = 0;
}
public bool TryParseString(out ReadOnlySpan<char> value)
{
value = default;
SkipWhitespace();
if (_position >= _input.Length || _input[_position] != '"')
return false;
_position++; // Skip opening quote
int start = _position;
while (_position < _input.Length && _input[_position] != '"')
{
if (_input[_position] == '\\')
_position++; // Skip escaped char
_position++;
}
if (_position >= _input.Length)
return false;
value = _input.Slice(start, _position - start);
_position++; // Skip closing quote
return true;
}
public bool TryParseInt(out int value)
{
value = 0;
SkipWhitespace();
int start = _position;
while (_position < _input.Length && char.IsDigit(_input[_position]))
{
_position++;
}
if (start == _position)
return false;
return int.TryParse(_input.Slice(start, _position - start), out value);
}
private void SkipWhitespace()
{
while (_position < _input.Length && char.IsWhiteSpace(_input[_position]))
{
_position++;
}
}
}
// Использование
var json = "{\"name\":\"John\",\"age\":30}"u8;
var parser = new JsonParser(json);
if (parser.TryParseString(out var key1))
{
Console.WriteLine($"Key: {key1}");
}Задание 2: High-throughput Message Serializer
public class MessageSerializer
{
private static readonly ArrayPool<byte> _pool = ArrayPool<byte>.Shared;
public static byte[] Serialize(Message message)
{
int estimatedSize = 64 + message.Payload.Length;
byte[] buffer = _pool.Rent(estimatedSize);
try
{
Span<byte> span = buffer.AsSpan();
int offset = 0;
// Write header
MemoryMarshal.Write(span.Slice(offset), message.Type);
offset += sizeof(MessageType);
MemoryMarshal.Write(span.Slice(offset), message.Payload.Length);
offset += sizeof(int);
// Write payload
message.Payload.CopyTo(span.Slice(offset));
offset += message.Payload.Length;
// Return exact copy
byte[] result = new byte[offset];
span.Slice(0, offset).CopyTo(result);
return result;
}
finally
{
_pool.Return(buffer);
}
}
public static Message Deserialize(ReadOnlySpan<byte> data)
{
int offset = 0;
var type = MemoryMarshal.Read<MessageType>(data.Slice(offset));
offset += sizeof(MessageType);
int length = MemoryMarshal.Read<int>(data.Slice(offset));
offset += sizeof(int);
byte[] payload = _pool.Rent(length);
try
{
data.Slice(offset, length).CopyTo(payload);
return new Message(type, payload.AsSpan(0, length).ToArray());
}
finally
{
_pool.Return(payload);
}
}
}Задание 3: Оптимизация StringBuilder через Span
// ПЛОХО — StringBuilder аллоцирует internal buffer
string BuildString(IEnumerable<int> numbers)
{
var sb = new StringBuilder();
foreach (var n in numbers)
{
sb.Append(n).Append(',');
}
return sb.ToString();
}
// ХОРОШО — Span + stackalloc для small outputs
string BuildStringOptimized(ReadOnlySpan<int> numbers)
{
if (numbers.Length == 0)
return string.Empty;
// Estimate size: up to 10 chars per number + comma
int estimatedSize = numbers.Length * 11;
Span<char> buffer;
char[]? pooledBuffer = null;
if (estimatedSize <= 256)
{
buffer = stackalloc char[256];
}
else
{
pooledBuffer = ArrayPool<char>.Shared.Rent(estimatedSize);
buffer = pooledBuffer.AsSpan(0, estimatedSize);
}
try
{
int pos = 0;
foreach (var n in numbers)
{
if (!n.TryFormat(buffer.Slice(pos), out int written, default, CultureInfo.InvariantCulture))
{
// Buffer too small — switch to pooled
if (pooledBuffer == null)
{
pooledBuffer = ArrayPool<char>.Shared.Rent(estimatedSize * 2);
buffer.Slice(0, pos).CopyTo(pooledBuffer);
buffer = pooledBuffer.AsSpan(0, estimatedSize * 2);
}
}
pos += written;
buffer[pos++] = ',';
}
return new string(buffer.Slice(0, pos - 1));
}
finally
{
if (pooledBuffer != null)
{
ArrayPool<char>.Shared.Return(pooledBuffer);
}
}
}Контрольные вопросы
- Почему
Span<T>нельзя использовать в async методах? - Когда использовать
ArrayPool<T>vsstackalloc? - Что такое
ref structи зачем он нужен? - Как
ReadOnlySequence<T>помогает с stream processing? - Что делает
inparameter?
2.5 Large Object Heap и оптимизация
LOH Fragmentation — Проблема
Почему LOH фрагментируется
LOH по умолчанию не компактируется (до .NET 4.5.1), потому что:
- Копирование больших объектов дорого (memory bandwidth)
- Большие объекты часто долгоживущие
- Исторически LOH считался "rarely collected"
LOH Layout после нескольких аллокаций/освобождений:
[100KB][50KB freed][200KB][75KB freed][150KB][300KB freed][50KB]
↑ ↑ ↑
Fragment Fragment Fragment
Total free: 425KB
Largest contiguous: 300KB
New 250KB allocation → FAILS (no contiguous space)!Демонстрация фрагментации
class LohFragmentationDemo
{
static void Main()
{
Console.WriteLine("=== LOH Fragmentation Demo ===\n");
// Allocate large objects
var a = new byte[100_000]; // 100KB
var b = new byte[100_000]; // 100KB
var c = new byte[100_000]; // 100KB
var d = new byte[100_000]; // 100KB
var e = new byte[100_000]; // 100KB
Console.WriteLine($"LOH Size after allocation: {GC.GetGCMemoryInfo().SizeOfHeapInBytes / 1024}KB");
// Free alternating objects
b = null;
d = null;
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine($"LOH Size after freeing: {GC.GetGCMemoryInfo().SizeOfHeapInBytes / 1024}KB");
Console.WriteLine($"LOH Fragmentation: {(double)GC.GetGCMemoryInfo().FragmentationAfterBytes / GC.GetGCMemoryInfo().HeapSizeBytes * 100:F1}%");
// Try to allocate 150KB — может fail из-за fragmentation
try
{
var f = new byte[150_000]; // Needs contiguous space
Console.WriteLine("150KB allocation: SUCCESS");
}
catch (OutOfMemoryException)
{
Console.WriteLine("150KB allocation: FAILED (fragmentation)");
}
}
}GCSettings.LargeObjectHeapCompactionMode
Включение компактизации
// .NET 4.5.1+
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
// CompactOnce — компактизирует при следующей Gen 2 collection
// и сбрасывается в Default
// Для постоянной компактизации:
void EnableLohCompaction()
{
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
}Когда использовать компактизацию
public class LohManager
{
public static void CompactIfNeeded()
{
var info = GC.GetGCMemoryInfo();
// Компактизируем если фрагментация > 10%
if ((double)info.FragmentationAfterBytes / info.HeapSizeBytes > 0.10)
{
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Optimized);
}
}
}.NET 7+ — Ephemeral Strong Mode
<!-- .csproj -->
<PropertyGroup>
<GarbageCollectionAdaptationMode>1</GarbageCollectionAdaptationMode>
</PropertyGroup>Или через runtime config:
{
"runtimeOptions": {
"gcEphemeralStrongMode": true
}
}Буферизированные vs Небуферизированные потоки
Проблема больших буферов
// ПЛОХО — каждый FileStream аллоцирует buffer в LOH
async Task CopyFileAsync(string source, string dest)
{
using var input = new FileStream(source, FileMode.Open,
bufferSize: 1_000_000); // 1MB buffer → LOH!
using var output = new FileStream(dest, FileMode.Create,
bufferSize: 1_000_000); // 1MB buffer → LOH!
await input.CopyToAsync(output);
}Решение: Pooled buffers
// ХОРОШО — используем ArrayPool
async Task CopyFileOptimizedAsync(string source, string dest)
{
using var input = new FileStream(source, FileMode.Open,
bufferSize: 4096, useAsync: true);
using var output = new FileStream(dest, FileMode.Create,
bufferSize: 4096, useAsync: true);
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(64 * 1024); // 64KB from pool
try
{
int bytesRead;
while ((bytesRead = await input.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await output.WriteAsync(buffer, 0, bytesRead);
}
}
finally
{
pool.Return(buffer);
}
}System.IO.Pipelines
// Pipes автоматически управляют буферами
async Task ProcessPipelineAsync(PipeReader reader, PipeWriter writer)
{
while (true)
{
ReadResult result = await reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;
try
{
// Process data without copying
foreach (var segment in buffer)
{
ProcessSegment(segment.Span);
}
// Write output
var output = writer.GetMemory(4096);
int written = GenerateOutput(output.Span);
writer.Advance(written);
if (result.IsCompleted)
break;
}
finally
{
reader.AdvanceTo(buffer.End);
}
}
await writer.CompleteAsync();
}BoundedChannel для Backpressure
Проблема unbounded queues
// ПЛОХО — queue может расти бесконечно
class BadQueue
{
private readonly Queue<Message> _queue = new();
public void Enqueue(Message msg)
{
_queue.Enqueue(msg); // No limit → OOM under load!
}
}BoundedChannel решение
using System.Threading.Channels;
class GoodQueue
{
private readonly Channel<Message> _channel;
public GoodQueue(int capacity = 1000)
{
_channel = Channel.CreateBounded<Message>(new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait, // Backpressure
SingleReader = true,
SingleWriter = false
});
}
public async Task EnqueueAsync(Message msg, CancellationToken ct)
{
// Waits if channel is full — backpressure!
await _channel.Writer.WriteAsync(msg, ct);
}
public async Task ProcessAsync(CancellationToken ct)
{
await foreach (var msg in _channel.Reader.ReadAllAsync(ct))
{
await HandleMessageAsync(msg);
}
}
}BoundedChannel стратегии
// Wait — блокирует writer (default)
var waitChannel = Channel.CreateBounded<T>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
// DropOldest — удаляет oldest message
var dropOldest = Channel.CreateBounded<T>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest
});
// DropNewest — удаляет newest message
var dropNewest = Channel.CreateBounded<T>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropNewest
});
// DropWrite — silently drops write
var dropWrite = Channel.CreateBounded<T>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropWrite
});Практика
Задание 1: Демонстрация LOH Fragmentation
class LohFragmentationBenchmark
{
[Benchmark]
public void WithoutCompaction()
{
var objects = new List<byte[]>();
// Allocate
for (int i = 0; i < 100; i++)
{
objects.Add(new byte[100_000]);
}
// Free every other
for (int i = 1; i < objects.Count; i += 2)
{
objects[i] = null;
}
GC.Collect(2);
// Measure fragmentation
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"Fragmentation: {(double)info.FragmentationAfterBytes / info.HeapSizeBytes * 100:F1}%");
}
[Benchmark]
public void WithCompaction()
{
var objects = new List<byte[]>();
// Allocate
for (int i = 0; i < 100; i++)
{
objects.Add(new byte[100_000]);
}
// Free every other
for (int i = 1; i < objects.Count; i += 2)
{
objects[i] = null;
}
// Enable compaction
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2);
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"Fragmentation: {(double)info.FragmentationAfterBytes / info.HeapSizeBytes * 100:F1}%");
}
}Задание 2: Streaming Processor с Bounded Memory
public class BoundedMemoryProcessor
{
private readonly Channel<byte[]> _inputChannel;
private readonly Channel<byte[]> _outputChannel;
private readonly ArrayPool<byte> _pool;
private readonly int _maxBufferSize;
public BoundedMemoryProcessor(
int inputCapacity = 100,
int outputCapacity = 100,
int maxBufferSize = 64 * 1024)
{
_pool = ArrayPool<byte>.Shared;
_maxBufferSize = maxBufferSize;
_inputChannel = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(inputCapacity)
{
FullMode = BoundedChannelFullMode.Wait
});
_outputChannel = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(outputCapacity)
{
FullMode = BoundedChannelFullMode.Wait
});
}
public async Task EnqueueAsync(byte[] data, CancellationToken ct)
{
if (data.Length > _maxBufferSize)
throw new ArgumentException($"Data too large: {data.Length} > {_maxBufferSize}");
await _inputChannel.Writer.WriteAsync(data, ct);
}
public async Task ProcessAsync(CancellationToken ct)
{
await foreach (var data in _inputChannel.Reader.ReadAllAsync(ct))
{
try
{
var result = ProcessData(data);
await _outputChannel.Writer.WriteAsync(result, ct);
}
finally
{
if (data.Length >= 85_000)
{
_pool.Return(data);
}
}
}
}
public IAsyncEnumerable<byte[]> GetResultsAsync(CancellationToken ct)
{
return _outputChannel.Reader.ReadAllAsync(ct);
}
private byte[] ProcessData(byte[] data)
{
// Process without allocating large buffers
var result = _pool.Rent(data.Length);
try
{
// Transform data
data.AsSpan().CopyTo(result);
Transform(result.AsSpan(0, data.Length));
byte[] output = new byte[data.Length];
result.AsSpan(0, data.Length).CopyTo(output);
return output;
}
finally
{
_pool.Return(result);
}
}
private void Transform(Span<byte> data)
{
// Example transformation
for (int i = 0; i < data.Length; i++)
{
data[i] = (byte)(data[i] ^ 0xFF);
}
}
}Задание 3: Benchmark LOH Compaction
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class LohCompactionBenchmark
{
private List<byte[]>? _objects;
[GlobalSetup]
public void Setup()
{
_objects = new List<byte[]>(100);
}
[Benchmark(Baseline = true)]
public void NoCompaction()
{
AllocateAndFree();
GC.Collect(2);
}
[Benchmark]
public void WithCompaction()
{
AllocateAndFree();
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2);
}
private void AllocateAndFree()
{
_objects!.Clear();
for (int i = 0; i < 100; i++)
{
_objects.Add(new byte[100_000]);
}
for (int i = 1; i < _objects.Count; i += 2)
{
_objects[i] = null!;
}
}
}
// Run
var summary = BenchmarkRunner.Run<LohCompactionBenchmark>();Контрольные вопросы
- Почему LOH не компактируется по умолчанию?
- Как включить компактизацию LOH?
- Что такое backpressure и как BoundedChannel помогает?
- Какой размер buffer оптимален для FileStream?
- Как избежать LOH allocations при работе с большими данными?
- Использовать
ArrayPool<T>для reusable buffers - Использовать
System.IO.Pipelinesдля stream processing - Использовать
Span<T>для slicing без копирования - Использовать
BoundedChannelдля backpressure
2.6 Native Interop и память
Unsafe Code — Pointers, Fixed Buffers, Stackalloc
Pointers в C#
unsafe
{
// Указатель на int
int value = 42;
int* ptr = &value;
Console.WriteLine(*ptr); // 42
// Pointer arithmetic
int[] arr = { 1, 2, 3, 4, 5 };
fixed (int* p = arr)
{
Console.WriteLine(*(p + 2)); // 3
}
// Pointer to struct
Point point = new Point { X = 10, Y = 20 };
Point* pPoint = &point;
pPoint->X = 30; // point.X = 30
}
struct Point
{
public int X;
public int Y;
}Fixed Buffers
// Fixed buffer в struct (inline array)
unsafe struct FixedBuffer
{
public fixed byte Data[256]; // Inline 256 bytes
public int Length;
}
// Использование
unsafe
{
var buffer = new FixedBuffer();
buffer.Length = 10;
fixed (byte* p = buffer.Data)
{
for (int i = 0; i < buffer.Length; i++)
{
p[i] = (byte)i;
}
}
}Stackalloc
// Без unsafe (через Span)
Span<byte> span = stackalloc byte[256];
// С unsafe (прямые pointers)
unsafe
{
int* numbers = stackalloc int[100];
numbers[0] = 42;
byte* buffer = stackalloc byte[1024];
*buffer = 0xFF;
}Span с Unmanaged Memory
Создание Span из native памяти
unsafe
{
// Alloc native memory
IntPtr ptr = Marshal.AllocHGlobal(1024);
try
{
// Create Span from native memory
Span<byte> span = new Span<byte>((void*)ptr, 1024);
// Use span
span[0] = 42;
span.Fill(0xFF);
// Read back
Console.WriteLine(span[0]);
}
finally
{
Marshal.FreeHGlobal(ptr);
}
}NativeMemory (.NET 6+)
using System.Runtime.InteropServices;
// Allocation
unsafe
{
byte* ptr = (byte*)NativeMemory.Alloc(1024);
try
{
Span<byte> span = new Span<byte>(ptr, 1024);
span.Fill(42);
}
finally
{
NativeMemory.Free(ptr);
}
}
// Aligned allocation
unsafe
{
byte* aligned = (byte*)NativeMemory.AlignedAlloc(1024, 64); // 64-byte aligned
try
{
// Use for SIMD operations
}
finally
{
NativeMemory.AlignedFree(aligned);
}
}
// Zero-initialized
unsafe
{
int* zeros = (int*)NativeMemory.AllocZeroed(100, sizeof(int));
try
{
// Already zeroed
}
finally
{
NativeMemory.Free(zeros);
}
}P/Invoke Marshaling
Базовый P/Invoke
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadFile(
SafeFileHandle hFile,
byte[] lpBuffer,
int nNumberOfBytesToRead,
out int lpNumberOfBytesRead,
IntPtr lpOverlapped);
// Использование
byte[] buffer = new byte[1024];
bool success = ReadFile(handle, buffer, buffer.Length, out int bytesRead, IntPtr.Zero);Marshaling Problems
// ПЛОХО — marshaling копирует данные
[DllImport("native.dll")]
static extern void ProcessData(
[MarshalAs(UnmanagedType.LPArray)] byte[] data, // Copy!
int length);
// ХОРОШО — без копирования
unsafe
{
[DllImport("native.dll")]
static extern void ProcessData(byte* data, int length);
void Call(byte[] data)
{
fixed (byte* p = data)
{
ProcessData(p, data.Length); // No copy!
}
}
}Span с P/Invoke
unsafe class NativeInterop
{
[DllImport("native.dll")]
private static extern int NativeProcess(byte* data, int length);
public static int Process(Span<byte> data)
{
fixed (byte* p = &MemoryMarshal.GetReference(data))
{
// Handle empty span
byte dummy = 0;
byte* ptr = (p != null) ? p : &dummy;
return NativeProcess(ptr, data.Length);
}
}
}String Marshaling
// ПЛОХО — automatic marshaling allocates
[DllImport("native.dll", CharSet = CharSet.Unicode)]
static extern void ProcessString(string input); // Allocates native copy
// ХОРОШО — manual control
unsafe
{
[DllImport("native.dll")]
static extern void ProcessString(char* input, int length);
static void Call(ReadOnlySpan<char> input)
{
fixed (char* p = input)
{
ProcessString(p, input.Length);
}
}
}MemoryMarshal и Unsafe Class
MemoryMarshal
using System.Runtime.InteropServices;
// Cast Span<T> to Span<U> (reinterpret)
Span<int> ints = stackalloc int[10];
Span<byte> bytes = MemoryMarshal.AsBytes(ints); // 40 bytes
// Cast back
Span<int> ints2 = MemoryMarshal.Cast<byte, int>(bytes);
// Get reference to first element
ref int first = ref MemoryMarshal.GetReference(ints);
// Create Span from reference
Span<int> span = MemoryMarshal.CreateSpan(ref first, 10);
// Read/Write without bounds checking
int value = MemoryMarshal.Read<int>(bytes.Slice(0));
MemoryMarshal.Write<int>(bytes.Slice(0), 42);
// Get array segment
bool success = MemoryMarshal.TryGetArray<byte>(memory, out ArraySegment<byte> segment);
// String as Span<char>
ReadOnlySpan<char> span = "hello".AsSpan();
// TryRead for safe reading
if (MemoryMarshal.TryRead<int>(bytes, out int val))
{
Console.WriteLine(val);
}Unsafe Class
using System.Runtime.CompilerServices;
// Bit-cast (reinterpret)
float f = 3.14f;
int i = Unsafe.As<float, int>(ref f);
// Ref cast
object obj = "hello";
ref string s = ref Unsafe.As<object, string>(ref obj);
// Pointer operations
unsafe
{
int* ptr = stackalloc int[1];
*ptr = 42;
// Read/Write through pointer
int value = Unsafe.Read<int>(ptr);
Unsafe.Write(ptr, 100);
}
// SizeOf
int size = Unsafe.SizeOf<int>(); // 4
// Add to pointer (like pointer arithmetic)
unsafe
{
int* arr = stackalloc int[10];
int* elem3 = Unsafe.Add(arr, 3); // arr + 3
int* elem5 = Unsafe.Add(ref *arr, 5); // arr + 5
}
// Copy block
unsafe
{
byte* src = stackalloc byte[100];
byte* dst = stackalloc byte[100];
Unsafe.CopyBlock(dst, src, 100);
}
// Init block (memset)
unsafe
{
byte* buffer = stackalloc byte[100];
Unsafe.InitBlock(buffer, 0, 100); // Zero out
}MemoryMarshal + Unsafe Patterns
// Fast struct serialization
unsafe
{
public static byte[] Serialize<T>(T value) where T : unmanaged
{
byte[] result = new byte[Unsafe.SizeOf<T>()];
fixed (byte* p = result)
{
Unsafe.Write(p, value);
}
return result;
}
public static T Deserialize<T>(ReadOnlySpan<byte> data) where T : unmanaged
{
if (data.Length < Unsafe.SizeOf<T>())
throw new ArgumentException("Data too small");
fixed (byte* p = &MemoryMarshal.GetReference(data))
{
return Unsafe.Read<T>(p);
}
}
}
// Zero-copy parsing
public static ReadOnlySpan<byte> ExtractField(ReadOnlySpan<byte> data, int offset, int length)
{
if (offset + length > data.Length)
throw new ArgumentOutOfRangeException();
return data.Slice(offset, length);
}Практика
Задание 1: High-Performance Binary Protocol Parser
public ref struct BinaryParser
{
private ReadOnlySpan<byte> _data;
private int _position;
public BinaryParser(ReadOnlySpan<byte> data)
{
_data = data;
_position = 0;
}
public bool TryReadByte(out byte value)
{
if (_position >= _data.Length)
{
value = 0;
return false;
}
value = _data[_position++];
return true;
}
public bool TryReadInt32(out int value)
{
if (_position + sizeof(int) > _data.Length)
{
value = 0;
return false;
}
value = MemoryMarshal.Read<int>(_data.Slice(_position));
_position += sizeof(int);
return true;
}
public bool TryReadString(out ReadOnlySpan<byte> value)
{
value = default;
if (!TryReadInt32(out int length) || length < 0)
return false;
if (_position + length > _data.Length)
return false;
value = _data.Slice(_position, length);
_position += length;
return true;
}
public unsafe bool TryReadStruct<T>(out T value) where T : unmanaged
{
value = default;
int size = Unsafe.SizeOf<T>();
if (_position + size > _data.Length)
return false;
fixed (byte* p = &MemoryMarshal.GetReference(_data.Slice(_position)))
{
value = Unsafe.Read<T>(p);
}
_position += size;
return true;
}
}
// Protocol: [type:byte][length:int][payload:bytes]
public enum MessageType : byte
{
Text = 1,
Binary = 2,
Command = 3
}
public static Message ParseMessage(ReadOnlySpan<byte> data)
{
var parser = new BinaryParser(data);
if (!parser.TryReadByte(out byte typeByte))
throw new InvalidDataException("Missing type");
var type = (MessageType)typeByte;
if (!parser.TryReadInt32(out int length))
throw new InvalidDataException("Missing length");
if (!parser.TryReadString(out var payload))
throw new InvalidDataException("Missing payload");
return new Message(type, payload.ToArray());
}Задание 2: Оптимизация P/Invoke
// ПЛОХО — marshaling overhead
class BadNative
{
[DllImport("lib.dll")]
public static extern void ProcessArray(
[MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1)]
double[] data,
int length);
public static void Call(double[] data)
{
ProcessArray(data, data.Length); // Copies array to native!
}
}
// ХОРОШО — no marshaling
class GoodNative
{
[DllImport("lib.dll")]
private static extern unsafe void ProcessArrayNative(
double* data,
int length);
public static unsafe void Call(Span<double> data)
{
fixed (double* p = &MemoryMarshal.GetReference(data))
{
ProcessArrayNative(p, data.Length); // No copy!
}
}
}
// BEST — with ArrayPool for large data
class BestNative
{
private static readonly ArrayPool<double> _pool = ArrayPool<double>.Shared;
[DllImport("lib.dll")]
private static extern unsafe void ProcessArrayNative(
double* data,
int length);
public static unsafe void Process(ReadOnlySpan<double> input, Span<double> output)
{
if (input.Length != output.Length)
throw new ArgumentException("Length mismatch");
// For small data, use stack
if (input.Length <= 256)
{
Span<double> buffer = stackalloc double[input.Length];
input.CopyTo(buffer);
fixed (double* p = &MemoryMarshal.GetReference(buffer))
{
ProcessArrayNative(p, buffer.Length);
}
buffer.CopyTo(output);
}
else
{
// For large data, use pinned array
double[] buffer = _pool.Rent(input.Length);
try
{
input.CopyTo(buffer);
fixed (double* p = buffer)
{
ProcessArrayNative(p, input.Length);
}
buffer.AsSpan(0, input.Length).CopyTo(output);
}
finally
{
_pool.Return(buffer);
}
}
}
}Задание 3: Ring Buffer на Fixed-Size Buffers
public unsafe class RingBuffer<T> where T : unmanaged
{
private readonly T* _buffer;
private readonly int _capacity;
private int _head;
private int _tail;
private int _count;
public RingBuffer(int capacity)
{
_capacity = capacity;
_buffer = (T*)NativeMemory.Alloc((nuint)(capacity * Unsafe.SizeOf<T>()));
_head = 0;
_tail = 0;
_count = 0;
}
public void Enqueue(T item)
{
if (_count == _capacity)
throw new InvalidOperationException("Buffer full");
_buffer[_tail] = item;
_tail = (_tail + 1) % _capacity;
_count++;
}
public T Dequeue()
{
if (_count == 0)
throw new InvalidOperationException("Buffer empty");
T item = _buffer[_head];
_head = (_head + 1) % _capacity;
_count--;
return item;
}
public Span<T> AsSpan()
{
return new Span<T>(_buffer, _count);
}
public void Dispose()
{
NativeMemory.Free(_buffer);
}
}
// Usage
using var ringBuffer = new RingBuffer<int>(1024);
ringBuffer.Enqueue(42);
ringBuffer.Enqueue(100);
int value = ringBuffer.Dequeue(); // 42Контрольные вопросы
- Почему
fixedнужен при работе с arrays в unsafe code? - Когда использовать
MemoryMarshal.AsBytes? - Что делает
Unsafe.As<TFrom, TTo>? - Как избежать marshaling overhead в P/Invoke?
- Зачем нужен
NativeMemoryвместоMarshal.AllocHGlobal?
2.7 Продвинутые техники оптимизации памяти
Object Layout и Object Header
Структура объекта в .NET
Каждый объект в .NET имеет overhead:
Object Layout (64-bit):
┌─────────────────────┐
│ Method Table Ptr │ 8 bytes — тип объекта
├─────────────────────┤
│ Sync Block Index │ 8 bytes — для lock/Monitor
├─────────────────────┤
│ Instance Fields │ N bytes — данные
├─────────────────────┤
│ Padding │ 0-7 bytes — выравнивание
└─────────────────────┘
Minimum object size: 24 bytes (64-bit)Измерение размера объекта
using System.Runtime.CompilerServices;
// Approximate size
long GetObjectSize(object obj)
{
return GC.GetTotalObjectSize(obj); // .NET 10+
}
// Manual calculation
struct SmallStruct
{
public byte A; // 1 byte
public int B; // 4 bytes
public byte C; // 1 byte
}
// Size: 12 bytes (1 + 3 padding + 4 + 1 + 3 padding)
struct OptimizedStruct
{
public int B; // 4 bytes
public byte A; // 1 byte
public byte C; // 1 byte
}
// Size: 8 bytes (4 + 1 + 1 + 2 padding)StructLayout, Pack, FieldOffset
StructLayout
using System.Runtime.InteropServices;
// Auto (default) — CLR решает
[StructLayout(LayoutKind.Auto)]
struct AutoStruct { }
// Sequential — поля в порядке объявления
[StructLayout(LayoutKind.Sequential)]
struct SequentialStruct
{
public int A;
public byte B;
public short C;
}
// Explicit — ручной контроль
[StructLayout(LayoutKind.Explicit)]
struct ExplicitStruct
{
[FieldOffset(0)] public int Value;
[FieldOffset(0)] public byte Byte1;
[FieldOffset(1)] public byte Byte2;
[FieldOffset(2)] public byte Byte3;
[FieldOffset(3)] public byte Byte4;
}
// Usage — union-like behavior
var s = new ExplicitStruct { Value = 0x01020304 };
Console.WriteLine(s.Byte1); // 4 (little-endian)
Console.WriteLine(s.Byte2); // 3Pack — выравнивание
// Pack = 1 — no padding
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct PackedStruct
{
public byte A; // 1 byte
public int B; // 4 bytes
public byte C; // 1 byte
}
// Total: 6 bytes (no padding)
// Pack = 4 (default for most)
[StructLayout(LayoutKind.Sequential, Pack = 4)]
struct DefaultStruct
{
public byte A; // 1 byte + 3 padding
public int B; // 4 bytes
public byte C; // 1 byte + 3 padding
}
// Total: 12 bytes
// Pack = 8
[StructLayout(LayoutKind.Sequential, Pack = 8)]
struct AlignedStruct
{
public byte A; // 1 byte + 7 padding
public long B; // 8 bytes
public byte C; // 1 byte + 7 padding
}
// Total: 24 bytesFieldOffset для оптимизации
// Optimized layout — минимальный padding
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct OptimizedMessage
{
public int Id; // 4 bytes
public short Type; // 2 bytes
public byte Priority; // 1 byte
public byte Flags; // 1 byte
public long Timestamp; // 8 bytes
}
// Total: 16 bytes
// Unoptimized — больше padding
[StructLayout(LayoutKind.Sequential)]
struct UnoptimizedMessage
{
public byte Priority; // 1 + 7 padding
public long Timestamp; // 8
public int Id; // 4
public short Type; // 2 + 2 padding
public byte Flags; // 1 + 3 padding
}
// Total: 32 bytes (2x larger!)readonly struct и Inlining Optimizations
readonly struct
// readonly struct гарантирует immutability
readonly struct Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
}
// Преимущества:
// 1. JIT может inline методы агрессивнее
// 2. Можно использовать с `in` parameters без defensive copy
// 3. Compiler предотвращает accidental mutationDefensive Copy Problem
struct MutablePoint
{
public int X;
public int Y;
}
class Container
{
private MutablePoint _point;
public MutablePoint Point => _point; // Property returns COPY
public void Test()
{
Point.X = 10; // Modifies COPY, not _point!
}
}
// Solution: readonly struct
readonly struct ImmutablePoint
{
public int X { get; }
public int Y { get; }
}
class Container2
{
private ImmutablePoint _point;
public ImmutablePoint Point => _point; // No defensive copy needed!
}ref readonly returns
readonly struct LargeData
{
public readonly int A, B, C, D, E, F, G, H;
}
class DataStore
{
private LargeData _data;
// ref readonly — no copy, no mutation
public ref readonly LargeData GetData()
{
return ref _data;
}
}
// Usage
var store = new DataStore();
ref readonly var data = ref store.GetData();
Console.WriteLine(data.A); // No copy!
// data.A = 10; // Error: readonlyJIT Optimizations
Devirtualization
// JIT может devirtualize calls когда тип известен
interface IProcessor
{
void Process();
}
sealed class FastProcessor : IProcessor
{
public void Process() { } // sealed — JIT знает точный тип
}
void CallProcessor(IProcessor processor)
{
processor.Process(); // Virtual call
if (processor is FastProcessor fast)
{
fast.Process(); // Devirtualized — direct call!
}
}Inlining
// JIT автоматически inlines small methods
int Add(int a, int b) => a + b; // Will be inlined
// Force inline
[MethodImpl(MethodImplOptions.AggressiveInlining)]
int FastAdd(int a, int b) => a + b;
// Prevent inline (для debugging)
[MethodImpl(MethodImplOptions.NoInlining)]
void DebugMethod() { }
// Inlining conditions:
// ✅ Small methods (< 32 bytes IL)
// ✅ Non-virtual methods
// ✅ Methods without try/catch
// ✅ Methods without loops (sometimes)
// ❌ Virtual methods (unless devirtualized)
// ❌ Methods with EH clauses
// ❌ Large methodsEscape Analysis
// Escape analysis определяет, может ли объект "сбежать" из метода
void NoEscape()
{
var obj = new object(); // Doesn't escape
obj.ToString();
} // obj can be stack-allocated (theoretical)
void Escapes()
{
var obj = new object();
Store(obj); // obj escapes — must be heap-allocated
}
void Store(object obj) { }
// .NET JIT currently does NOT do escape analysis for stack allocation
// But it does use it for:
// - Register allocation
// - Dead code elimination
// - Lock elisionLock Elision
// JIT может eliminate locks для non-escaping objects
void LockElisionExample()
{
var obj = new object(); // Doesn't escape
lock (obj) // May be elided by JIT
{
// Critical section
}
}Renting vs Allocating Patterns
Object Pooling
public class PooledObject<T> : IDisposable where T : class, new()
{
private static readonly ObjectPool<T> _pool =
new DefaultObjectPool<T>(new DefaultPooledObjectPolicy<T>());
private T? _value;
private bool _disposed;
public T Value => _value ??= _pool.Get();
public void Dispose()
{
if (!_disposed)
{
if (_value != null)
{
_pool.Return(_value);
_value = null;
}
_disposed = true;
}
}
}
// Usage
using var pooled = new PooledObject<ExpensiveObject>();
pooled.Value.DoWork();Renting Pattern для High-Load
public class RentingProcessor
{
private static readonly ArrayPool<byte> _bytePool = ArrayPool<byte>.Shared;
private static readonly ArrayPool<char> _charPool = ArrayPool<char>.Shared;
private static readonly ArrayPool<int> _intPool = ArrayPool<int>.Shared;
public void Process(Stream input, Stream output)
{
byte[] readBuffer = _bytePool.Rent(64 * 1024);
byte[] writeBuffer = _bytePool.Rent(64 * 1024);
char[] charBuffer = _charPool.Rent(32 * 1024);
int[] indexBuffer = _intPool.Rent(1024);
try
{
// Process using rented buffers
int bytesRead = input.Read(readBuffer, 0, readBuffer.Length);
// Transform
Transform(readBuffer.AsSpan(0, bytesRead), writeBuffer);
// Output
output.Write(writeBuffer, 0, bytesRead);
}
finally
{
_bytePool.Return(readBuffer);
_bytePool.Return(writeBuffer);
_charPool.Return(charBuffer);
_intPool.Return(indexBuffer);
}
}
private void Transform(ReadOnlySpan<byte> input, Span<byte> output)
{
// Zero-allocation transform
for (int i = 0; i < input.Length; i++)
{
output[i] = (byte)(input[i] ^ 0xFF);
}
}
}Slab Allocation
// Slab allocator для fixed-size objects
public class SlabAllocator<T> where T : class, new()
{
private const int SlabSize = 1024;
private readonly Stack<T> _freeList = new();
private int _slabIndex;
private T[]?[] _slabs = new T[16][];
public T Allocate()
{
if (_freeList.Count > 0)
return _freeList.Pop();
if (_slabIndex >= _slabs.Length)
Array.Resize(ref _slabs, _slabs.Length * 2);
if (_slabs[_slabIndex] == null)
_slabs[_slabIndex] = new T[SlabSize];
var slab = _slabs[_slabIndex]!;
int itemIndex = slab.Length - SlabSize + (_slabIndex * SlabSize) % SlabSize;
if (itemIndex >= SlabSize)
{
_slabIndex++;
return Allocate();
}
return slab[itemIndex] ??= new T();
}
public void Return(T obj)
{
_freeList.Push(obj);
}
}Практика
Задание 1: Оптимизация структуры данных
// BEFORE — неоптимальный layout
[StructLayout(LayoutKind.Auto)]
struct UnoptimizedRecord
{
public bool IsActive; // 1 byte + 7 padding
public long Id; // 8 bytes
public string Name; // 8 bytes (reference)
public short Priority; // 2 bytes + 6 padding
public int Score; // 4 bytes + 4 padding
public DateTime Created; // 8 bytes
public byte Flags; // 1 byte + 7 padding
}
// Total: ~56 bytes + string object
// AFTER — оптимизированный layout
[StructLayout(LayoutKind.Sequential, Pack = 8)]
struct OptimizedRecord
{
public long Id; // 8 bytes
public DateTime Created; // 8 bytes
public string Name; // 8 bytes (reference)
public int Score; // 4 bytes
public short Priority; // 2 bytes
public byte Flags; // 1 byte
public bool IsActive; // 1 byte
}
// Total: 40 bytes + string object (30% smaller!)
// BEST — для serialization
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct PackedRecord
{
public long Id; // 8 bytes
public long CreatedTicks; // 8 bytes (instead of DateTime)
public int Score; // 4 bytes
public short Priority; // 2 bytes
public byte Flags; // 1 byte
public bool IsActive; // 1 byte
}
// Total: 24 bytes (no reference overhead!)Задание 2: Arena Allocator
// Arena allocator для batch processing
public sealed class ArenaAllocator : IDisposable
{
private readonly List<byte[]> _slabs = new();
private readonly int _slabSize;
private int _offset;
private bool _disposed;
public ArenaAllocator(int slabSize = 64 * 1024)
{
_slabSize = slabSize;
_slabs.Add(new byte[slabSize]);
_offset = 0;
}
public unsafe T* Allocate<T>() where T : unmanaged
{
int size = Unsafe.SizeOf<T>();
if (_offset + size > _slabSize)
{
_slabs.Add(new byte[_slabSize]);
_offset = 0;
}
var slab = _slabs[_slabs.Count - 1];
fixed (byte* p = slab)
{
T* ptr = (T*)(p + _offset);
_offset += size;
return ptr;
}
}
public unsafe Span<T> AllocateSpan<T>(int count) where T : unmanaged
{
int size = count * Unsafe.SizeOf<T>();
if (_offset + size > _slabSize)
{
_slabs.Add(new byte[Math.Max(_slabSize, size)]);
_offset = 0;
}
var slab = _slabs[_slabs.Count - 1];
fixed (byte* p = slab)
{
Span<T> span = new Span<T>(p + _offset, count);
_offset += size;
return span;
}
}
public void Reset()
{
_offset = 0;
if (_slabs.Count > 1)
{
_slabs.RemoveRange(1, _slabs.Count - 1);
}
}
public void Dispose()
{
if (!_disposed)
{
_slabs.Clear();
_disposed = true;
}
}
}
// Usage
using var arena = new ArenaAllocator();
// Allocate multiple objects without individual allocations
unsafe
{
var point1 = arena.Allocate<Point>();
point1->X = 10;
point1->Y = 20;
var point2 = arena.Allocate<Point>();
point2->X = 30;
point2->Y = 40;
// Allocate array
Span<int> numbers = arena.AllocateSpan<int>(100);
numbers.Fill(42);
}
// All memory freed at once when arena is disposedЗадание 3: Escape Analysis через JIT Dumps
// Для анализа escape analysis используйте:
// DOTNET_JitDisasm=* Program.exe
// или
// DOTNET_JitDump=* Program.exe
class EscapeAnalysisDemo
{
// Этот объект НЕ escape — может быть оптимизирован
static void NoEscape()
{
var obj = new object();
Console.WriteLine(obj.GetHashCode());
}
// Этот объект ESCAPE — должен быть в куче
static void Escapes(object container)
{
var obj = new object();
Store(container, obj);
}
static void Store(object container, object obj) { }
// Lock elision candidate
static void LockElision()
{
var sync = new object();
lock (sync) // JIT может eliminate этот lock
{
Console.WriteLine("Inside lock");
}
}
}
// Запуск с JIT disassembly:
// DOTNET_TieredCompilation=0 DOTNET_JitDisasm=* dotnet runКонтрольные вопросы
- Почему порядок полей в struct влияет на размер?
- Что такое defensive copy и как его избежать?
- Когда JIT может devirtualize вызов?
- Что такое arena allocator и когда его использовать?
- Как уменьшить размер объекта в куче?
- Использовать
structвместоclassдля small data - Оптимизировать порядок полей (большие поля primero)
- Использовать
Pack = 1для serialization structs - Избегать unnecessary references
2.8 Memory-Constrained Architectures
Serverless / Container Memory Limits
Особенности constrained environments
┌─────────────────────────────────────────────────┐
│ Memory Constraints │
├──────────────────┬──────────────────────────────┤
│ Serverless │ Containers │
│ (AWS Lambda) │ (Docker/K8s) │
├──────────────────┼──────────────────────────────┤
│ 128MB - 10GB │ 64MB - N │
│ Cold starts │ Steady state │
│ Short-lived │ Long-running │
│ Max duration │ OOM kills │
└──────────────────┴──────────────────────────────┘.NET в Serverless
// AWS Lambda — минимизируйте memory footprint
public class Function
{
// ПЛОХО — static cache занимает память
private static readonly Dictionary<string, object> _cache = new();
// ХОРОШО — без cache, minimal allocations
public async Task<APIGatewayProxyResponse> FunctionHandler(
APIGatewayProxyRequest request,
ILambdaContext context)
{
// Use Span<T> for processing
var body = request.Body;
ReadOnlySpan<char> span = body.AsSpan();
// Process without allocations
var result = Process(span);
return new APIGatewayProxyResponse
{
StatusCode = 200,
Body = result
};
}
}Container Memory Limits
# Dockerfile — настройка GC для контейнера
FROM mcr.microsoft.com/dotnet/aspnet:8.0
# Set GC mode for containers
ENV DOTNET_gcServer=0
ENV DOTNET_GCHeapHardLimit=0x10000000 # 256MB
COPY . /app
WORKDIR /app
ENTRYPOINT ["dotnet", "MyApp.dll"]# Kubernetes — memory limits
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
containers:
- name: myapp
image: myapp:latest
resources:
limits:
memory: "256Mi"
requests:
memory: "128Mi"
env:
- name: DOTNET_gcServer
value: "0"
- name: DOTNET_GCHeapHardLimit
value: "0x10000000"GC Tuning для Constrained Environments
ServerGC vs WorkstationGC
<!-- .csproj -->
<PropertyGroup>
<!-- Для контейнеров < 512MB — Workstation GC -->
<ServerGarbageCollection>false</ServerGarbageCollection>
<!-- Для контейнеров > 1GB — Server GC -->
<!-- <ServerGarbageCollection>true</ServerGarbageCollection> -->
<!-- Concurrent GC для low latency -->
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<!-- Retain VM — не возвращать память OS (для steady state) -->
<RetainVMGarbageCollection>false</RetainVMGarbageCollection>
</PropertyGroup>Environment Variables
# Server GC (для multi-core, > 1GB)
DOTNET_gcServer=1
# Workstation GC (для single-core, < 512MB)
DOTNET_gcServer=0
# Concurrent GC
DOTNET_gcConcurrent=1
# Heap hard limit (hex bytes)
DOTNET_GCHeapHardLimit=0x10000000 # 256MB
# Heap hard limit percent (of container limit)
DOTNET_GCHeapHardLimitPercent=80 # 80% of container limit
# Retain VM (keep memory for reuse)
DOTNET_GCRetainVM=0 # Return memory to OS
# LOH compaction
DOTNET_gcLOHCompaction=1
# No GC region (для critical sections)
DOTNET_NoGCRegion=0HeapAffinity
// HeapAffinity — привязка GC heaps к specific cores
// Полезно для NUMA systems
// runtimeconfig.json
{
"runtimeOptions": {
"gcHeapAffinitizeMask": 0x0000000F, // Cores 0-3
"gcHeapCount": 4, // 4 heaps
"gcNoGCRegionMode": 0
}
}Ephemeral Strong Mode
Что такое Ephemeral Strong Mode
Ephemeral Strong Mode — режим GC (.NET 7+), который оптимизирует Gen 0/1 коллекции для low-memory environments.
<!-- .csproj -->
<PropertyGroup>
<GarbageCollectionAdaptationMode>1</GarbageCollectionAdaptationMode>
</PropertyGroup>Преимущества:
- Меньше Gen 2 collections
- Better memory utilization в constrained environments
- Faster Gen 0/1 collections
Когда использовать
Use Ephemeral Strong Mode when:
├── Container memory < 512MB
├── High allocation rate
├── Short-lived objects dominate
└── Need to minimize Gen 2 collectionsHybrid GC Modes (.NET 8+)
GC Mode Selection
┌─────────────────────────────────────────────────┐
│ GC Mode Selection │
├─────────────────────────────────────────────────┤
│ Memory < 256MB → Workstation, Concurrent │
│ Memory 256-1GB → Workstation, Ephemeral Strong│
│ Memory 1-4GB → Server, Concurrent │
│ Memory > 4GB → Server, Concurrent, RetainVM│
└─────────────────────────────────────────────────┘Dynamic GC Configuration
public static class GcConfigurator
{
public static void ConfigureForEnvironment()
{
long totalMemory = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
if (totalMemory <= 256 * 1024 * 1024) // 256MB
{
// Low memory — workstation GC
Console.WriteLine("Configuring for low memory");
// Set via runtimeconfig.json or env vars
}
else if (totalMemory <= 1024 * 1024 * 1024) // 1GB
{
// Medium memory — workstation with ephemeral strong
Console.WriteLine("Configuring for medium memory");
}
else
{
// High memory — server GC
Console.WriteLine("Configuring for high memory");
}
}
}Практика
Задание 1: Настройка GC для контейнера 256MB
# Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
# Memory-optimized GC settings
ENV DOTNET_gcServer=0
ENV DOTNET_gcConcurrent=1
ENV DOTNET_GCHeapHardLimitPercent=80
ENV DOTNET_GCLOHCompaction=1
ENV DOTNET_GCHardLimit=0x10000000
# Disable features we don't need
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
ENV DOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLER=0
COPY . /app
WORKDIR /app
ENTRYPOINT ["dotnet", "MyApp.dll"]// runtimeconfig.template.json
{
"runtimeOptions": {
"gcServer": false,
"gcConcurrent": true,
"gcHeapHardLimitPercent": 80,
"gcLargeObjectHeapCompaction": true,
"garbageCollectionAdaptationMode": 1
}
}Задание 2: Graceful Degradation при Memory Pressure
public class MemoryAwareService
{
private readonly ILogger _logger;
private readonly int _memoryThreshold;
private volatile bool _isUnderPressure;
public MemoryAwareService(ILogger logger, int memoryThresholdMB = 200)
{
_logger = logger;
_memoryThreshold = memoryThresholdMB * 1024 * 1024;
// Start memory monitoring
_ = MonitorMemoryAsync(CancellationToken.None);
}
public async Task<Response> ProcessRequestAsync(Request request)
{
if (_isUnderPressure)
{
_logger.LogWarning("Memory pressure — using degraded mode");
return await ProcessDegradedAsync(request);
}
return await ProcessNormalAsync(request);
}
private async Task<Response> ProcessNormalAsync(Request request)
{
// Full processing with caching
var cache = GetCache();
var result = await HeavyProcessing(request, cache);
return result;
}
private async Task<Response> ProcessDegradedAsync(Request request)
{
// Degraded processing — no cache, minimal allocations
var result = await LightweightProcessing(request);
return result;
}
private async Task MonitorMemoryAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
long workingSet = Environment.WorkingSet;
if (workingSet > _memoryThreshold && !_isUnderPressure)
{
_isUnderPressure = true;
_logger.LogWarning($"Memory pressure detected: {workingSet / 1024 / 1024}MB");
// Trigger GC to try to free memory
GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized);
}
else if (workingSet < _memoryThreshold * 0.7 && _isUnderPressure)
{
_isUnderPressure = false;
_logger.LogInformation($"Memory pressure relieved: {workingSet / 1024 / 1024}MB");
}
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
}Задание 3: Load Test для Memory Constraints
using System.Diagnostics;
public class MemoryConstraintLoadTest
{
private readonly int _maxMemoryMB;
private readonly int _durationSeconds;
private readonly int _requestsPerSecond;
public MemoryConstraintLoadTest(int maxMemoryMB, int durationSeconds, int rps)
{
_maxMemoryMB = maxMemoryMB;
_durationSeconds = durationSeconds;
_requestsPerSecond = rps;
}
public async Task<LoadTestResult> RunAsync(Func<Task> requestHandler)
{
var cts = new CancellationTokenSource(_durationSeconds * 1000);
var results = new List<RequestResult>();
var memorySamples = new List<MemorySample>();
// Memory monitoring task
var monitorTask = Task.Run(async () =>
{
while (!cts.Token.IsCancellationRequested)
{
memorySamples.Add(new MemorySample(
DateTime.UtcNow,
Environment.WorkingSet,
GC.GetTotalMemory(false)));
await Task.Delay(100, cts.Token);
}
});
// Load generation task
var loadTask = Task.Run(async () =>
{
var interval = TimeSpan.FromSeconds(1.0 / _requestsPerSecond);
var nextRequest = DateTime.UtcNow;
while (!cts.Token.IsCancellationRequested)
{
var start = DateTime.UtcNow;
try
{
await requestHandler();
results.Add(new RequestResult(start, true, null));
}
catch (Exception ex)
{
results.Add(new RequestResult(start, false, ex.Message));
}
nextRequest = nextRequest.Add(interval);
var delay = nextRequest - DateTime.UtcNow;
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, cts.Token);
}
}
});
await Task.WhenAll(monitorTask, loadTask);
// Analyze results
var maxMemory = memorySamples.Max(s => s.WorkingSet);
var successRate = results.Count(r => r.Success) / (double)results.Count;
var avgLatency = results.Where(r => r.Success)
.Average(r => (DateTime.UtcNow - r.Timestamp).TotalMilliseconds);
return new LoadTestResult(
TotalRequests: results.Count,
SuccessRate: successRate,
MaxMemoryMB: maxMemory / 1024 / 1024,
AvgLatencyMs: avgLatency,
MemorySamples: memorySamples,
Passed: maxMemory <= _maxMemoryMB * 1024 * 1024 && successRate > 0.99);
}
}
public record MemorySample(DateTime Timestamp, long WorkingSet, long TotalMemory);
public record RequestResult(DateTime Timestamp, bool Success, string? Error);
public record LoadTestResult(
int TotalRequests,
double SuccessRate,
long MaxMemoryMB,
double AvgLatencyMs,
List<MemorySample> MemorySamples,
bool Passed);
// Usage
var test = new MemoryConstraintLoadTest(
maxMemoryMB: 256,
durationSeconds: 60,
rps: 1000);
var result = await test.RunAsync(async () =>
{
// Simulate request
await Task.Delay(1);
});
Console.WriteLine($"Passed: {result.Passed}");
Console.WriteLine($"Max Memory: {result.MaxMemoryMB}MB");
Console.WriteLine($"Success Rate: {result.SuccessRate:P2}");Контрольные вопросы
- Какой GC mode лучше для контейнера с 256MB RAM?
- Что такое Ephemeral Strong Mode?
- Как реализовать graceful degradation при memory pressure?
- Зачем нужен
GCHeapHardLimit? - Как тестировать стабильность под memory constraints?
2.9 Контрольная точка модуля 2
Высоконагруженный Message Processing Pipeline
Описание проекта
Создать message processing pipeline, который обрабатывает сообщения с минимальным использованием памяти и гарантированным bounded memory usage.
Требования
Zero-Allocation Hot Path
// Интерфейс процессора
public interface IMessageProcessor
{
// Hot path — должен быть zero-allocation
void ProcessMessage(ReadOnlySpan<byte> message, Span<byte> output);
}
// Реализация с Span<T> и ArrayPool<T>
public class OptimizedMessageProcessor : IMessageProcessor
{
private static readonly ArrayPool<byte> _pool = ArrayPool<byte>.Shared;
public void ProcessMessage(ReadOnlySpan<byte> message, Span<byte> output)
{
// Zero-allocation processing
// 1. Parse message header
// 2. Transform payload
// 3. Write to output
// NO new allocations in hot path!
}
}Bounded Memory Usage (< 50MB)
// Pipeline с bounded memory
public class BoundedPipeline
{
private readonly Channel<byte[]> _inputChannel;
private readonly Channel<byte[]> _outputChannel;
private readonly int _maxMemoryBytes;
private volatile int _currentMemoryUsage;
public BoundedPipeline(int maxMemoryMB = 50)
{
_maxMemoryBytes = maxMemoryMB * 1024 * 1024;
// Bounded channels для backpressure
_inputChannel = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait
});
_outputChannel = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait
});
}
public async Task EnqueueAsync(byte[] message, CancellationToken ct)
{
// Check memory before accepting
if (_currentMemoryUsage + message.Length > _maxMemoryBytes)
{
throw new MemoryLimitExceededException(
$"Would exceed {_maxMemoryBytes / 1024 / 1024}MB limit");
}
Interlocked.Add(ref _currentMemoryUsage, message.Length);
await _inputChannel.Writer.WriteAsync(message, ct);
}
public async Task ProcessAsync(CancellationToken ct)
{
await foreach (var message in _inputChannel.Reader.ReadAllAsync(ct))
{
try
{
var output = ProcessMessage(message);
await _outputChannel.Writer.WriteAsync(output, ct);
}
finally
{
// Return memory to pool
Interlocked.Add(ref _currentMemoryUsage, -message.Length);
if (message.Length >= 85_000)
{
_pool.Return(message);
}
}
}
}
}Custom GC Configuration
// runtimeconfig.template.json
{
"runtimeOptions": {
"gcServer": true,
"gcConcurrent": true,
"gcHeapHardLimit": "0x3200000",
"gcLargeObjectHeapCompaction": true,
"garbageCollectionAdaptationMode": 1
}
}// GC configuration at startup
public static class GcSetup
{
public static void Configure()
{
// Verify GC settings
Console.WriteLine($"Server GC: {GCSettings.IsServerGC}");
Console.WriteLine($"Concurrent GC: {GCSettings.LatencyMode}");
// Set LOH compaction
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
}
}Memory Metrics Dashboard
// Memory metrics collector
public class MemoryMetricsCollector
{
private readonly Timer _timer;
private readonly List<MemorySnapshot> _snapshots = new();
public MemoryMetricsCollector()
{
_timer = new Timer(CollectSnapshot, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}
private void CollectSnapshot(object? state)
{
var info = GC.GetGCMemoryInfo();
var snapshot = new MemorySnapshot(
Timestamp: DateTime.UtcNow,
WorkingSet: Environment.WorkingSet,
Gen0Collections: GC.CollectionCount(0),
Gen1Collections: GC.CollectionCount(1),
Gen2Collections: GC.CollectionCount(2),
HeapSize: info.HeapSizeBytes,
Fragmentation: info.FragmentationAfterBytes,
LohSize: info.SizeOfTotalObjectsInLOH);
_snapshots.Add(snapshot);
}
public IReadOnlyList<MemorySnapshot> GetSnapshots() => _snapshots;
}
public record MemorySnapshot(
DateTime Timestamp,
long WorkingSet,
int Gen0Collections,
int Gen1Collections,
int Gen2Collections,
long HeapSize,
double Fragmentation,
long LohSize);Критерии прохождения
Allocation Rate < 1 MB/sec при 10k msg/sec
[Benchmark]
public void ProcessMessages()
{
var processor = new OptimizedMessageProcessor();
var input = new byte[1024];
var output = new byte[1024];
for (int i = 0; i < 10_000; i++)
{
processor.ProcessMessage(input, output);
}
}
// Target: < 100 KB allocated per 10k messages
// = < 1 MB/sec allocation rate at 10k msg/secNo Gen 2 Collections During Steady State
// Monitor during load test
var gen2Before = GC.CollectionCount(2);
await RunLoadTestAsync(duration: TimeSpan.FromMinutes(5));
var gen2After = GC.CollectionCount(2);
var gen2Collections = gen2After - gen2Before;
Assert.That(gen2Collections, Is.EqualTo(0),
"No Gen 2 collections during steady state");LOH Fragmentation < 5%
var info = GC.GetGCMemoryInfo();
var fragmentationPercent = (double)info.FragmentationAfterBytes / info.HeapSizeBytes * 100;
Assert.That(fragmentationPercent, Is.LessThan(5),
"LOH fragmentation must be < 5%");Все Memory Leaks Обнаружены и Устранены
// Leak detection test
[Test]
public void NoMemoryLeaks()
{
var weakRefs = new List<WeakReference>();
// Create and process messages
for (int i = 0; i < 1000; i++)
{
var msg = CreateMessage();
weakRefs.Add(new WeakReference(msg));
ProcessMessage(msg);
msg = null;
}
// Force GC
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
// Check for leaks
var leaked = weakRefs.Count(wr => wr.IsAlive);
Assert.That(leaked, Is.EqualTo(0),
$"Found {leaked} leaked objects");
}Архитектура проекта
MessagePipeline/
├── src/
│ ├── Pipeline/
│ │ ├── BoundedPipeline.cs # Main pipeline with backpressure
│ │ ├── IMessageProcessor.cs # Processor interface
│ │ ├── OptimizedProcessor.cs # Zero-allocation implementation
│ │ └── MemoryMetricsCollector.cs # Metrics collection
│ ├── Memory/
│ │ ├── PooledBuffer.cs # ArrayPool wrapper
│ │ ├── MemoryLimiter.cs # Memory usage tracking
│ │ └── ArenaAllocator.cs # Batch allocation
│ └── Program.cs # Entry point
├── tests/
│ ├── PipelineTests.cs # Functional tests
│ ├── MemoryTests.cs # Memory constraint tests
│ ├── LeakDetectionTests.cs # Leak detection
│ └── BenchmarkTests.cs # Performance benchmarks
├── runtimeconfig.template.json # GC configuration
└── MessagePipeline.csprojПример реализации
// OptimizedProcessor.cs
public sealed class OptimizedMessageProcessor : IMessageProcessor, IDisposable
{
private static readonly ArrayPool<byte> _pool = ArrayPool<byte>.Shared;
private readonly byte[] _workingBuffer;
public OptimizedMessageProcessor(int maxMessageSize = 64 * 1024)
{
_workingBuffer = _pool.Rent(maxMessageSize);
}
public void ProcessMessage(ReadOnlySpan<byte> message, Span<byte> output)
{
// Use working buffer (pooled, no allocation)
Span<byte> buffer = _workingBuffer.AsSpan(0, message.Length);
message.CopyTo(buffer);
// Parse header (zero allocation)
var header = MemoryMarshal.Read<MessageHeader>(buffer);
// Transform payload (zero allocation)
TransformPayload(buffer.Slice(Unsafe.SizeOf<MessageHeader>()));
// Write output (zero allocation)
WriteOutput(buffer, output);
}
private void TransformPayload(Span<byte> payload)
{
// In-place transformation
for (int i = 0; i < payload.Length; i++)
{
payload[i] = (byte)(payload[i] ^ 0xFF);
}
}
private void WriteOutput(ReadOnlySpan<byte> input, Span<byte> output)
{
input.CopyTo(output);
}
public void Dispose()
{
_pool.Return(_workingBuffer);
}
}
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct MessageHeader
{
public int MessageId;
public short Type;
public byte Priority;
public byte Flags;
}Evaluation Rubric
| Критерий | Weight | Pass Criteria |
|---|---|---|
| Allocation Rate | 25% | < 1 MB/sec at 10k msg/sec |
| Gen 2 Collections | 20% | 0 during steady state (5 min) |
| LOH Fragmentation | 15% | < 5% |
| Memory Limit | 20% | < 50MB regardless of load |
| Leak Detection | 10% | 0 leaked objects |
| Code Quality | 10% | Proper IDisposable, pooling, Span usage |
Дополнительные задания (Optional)
- Prometheus Integration — экспортировать memory metrics
- Graceful Degradation — switch to lightweight mode при memory pressure
- Custom ArrayPool — implement specialized pool для message sizes
- Native Interop — integrate with native library для crypto operations
- Multi-threaded Pipeline — parallel processing с bounded memory
Submission
- GitHub repository с полным кодом
- BenchmarkDotNet результаты
- dotnet-counters output во время load test
- dotnet-gcdump analysis (no leaks)
- README с архитектурой и design decisions
Контрольные вопросы (Final)
- Как доказать что hot path zero-allocation?
Использовать BenchmarkDotNet с MemoryDiagnoser, проверить Allocated column = 0 или negligible.
- Почему bounded channels важны для memory constraints?
Предотвращают unbounded queue growth. Без backpressure producer может overwhelm consumer, causing OOM.
- Как настроить GC для production environment?
Зависит от memory: < 512MB → Workstation, > 1GB → Server. Всегда включать concurrent GC и LOH compaction.
- Что делать если Gen 2 collections происходят во время steady state?
- Проверить memory leaks (static caches, event handlers)
- Уменьшить allocation rate (Span, pooling)
- Увеличить heap size (если возможно)
- Настроить GC parameters
- Как доказать отсутствие memory leaks?
- WeakReference tracking tests
- GC dump comparison (before/after load)
- dotnet-counters monitoring (stable heap size)
- Profiler analysis (DotMemory, PerfView)