JavaRush /Курси /C# SELF /Низькорівневе керування памʼяттю у C#

Низькорівневе керування памʼяттю у C#

C# SELF
Рівень 65 , Лекція 1
Відкрита

1. Вступ

У попередній лекції ми розглядали, як C# і .NET Runtime беруть на себе значну частину клопотів із керування памʼяттю. Втім, інколи виникає потреба або бажання отримати ще нижчий рівень контролю над памʼяттю — для підвищення продуктивності чи взаємодії з некерованим кодом (наприклад, із системними API на C++). C# пропонує для цього низку інструментів. Вони потужні, проте потребують обережного застосування.

Коли ми говоримо про «низькорівневе керування памʼяттю» у C#, зазвичай маємо на увазі:

  • Пряму роботу з вказівниками: доступ до памʼяті за адресами, як у C/C++.
  • Виділення памʼяті поза купою GC: використання памʼяті, яку збирач сміття не відстежує.
  • Керування ресурсами на межі керованого й некерованого коду: ефективна взаємодія з нативними бібліотеками.

У C# звичайному коду не дозволено безпосередньо працювати з адресами памʼяті (вказівниками) задля безпеки та стабільності. Однак для низькорівневих операцій можна використовувати вказівники всередині unsafe-контексту.

Що таке unsafe-контекст?

Блок або метод, позначений ключовим словом unsafe, дозволяє використовувати синтаксис вказівників і виконувати операції, які CLR не перевіряє на безпеку. Код у unsafe-контексті не безпечніший за нативний код. Щоб використовувати unsafe-код, проєкт має бути скомпільовано з параметром /unsafe.

Приклад: Оголошення unsafe-методу та блоку


public unsafe class UnsafeExamples
{
    // Unsafe-метод
    public static unsafe void ManipulatePointer(int* ptr)
    {
        Console.WriteLine($"Значення за вказівником: {*ptr}");
        *ptr = 200; // Змінюємо значення за адресою
    }

    public static void DemoUnsafeBlock()
    {
        int value = 100;
        unsafe // Unsafe-блок усередині звичайного методу
        {
            int* ptr = &value; // Отримуємо адресу змінної
            ManipulatePointer(ptr); // Передаємо вказівник в unsafe-метод
            Console.WriteLine($"Нове значення: {value}"); // Вивід: Нове значення: 200
        }
    }
}

Типи вказівників

У C# можна оголошувати вказівники на значущі типи (int*, bool*, MyStruct*) і на void (void* для універсального вказівника). Не можна оголошувати вказівники безпосередньо на посилальні типи, але можна отримати вказівник на поле посилального типу, якщо це значущий тип.

Приклад: Різні типи вказівників


unsafe
{
    double d = 123.45;
    double* dPtr = &d; // Вказівник на double

    int[] numbers = { 1, 2, 3 };
    fixed (int* arrPtr = numbers) // 'fixed' фіксує об'єкт у пам'яті, щоб GC не перемістив його
    {
        Console.WriteLine($"Перший елемент масиву: {arrPtr[0]}");
        Console.WriteLine($"Другий елемент масиву: {*(arrPtr + 1)}");
    }
}

Оператори вказівників

  • & (адреса): отримує адресу змінної.
  • * (розіменування): отримує значення за адресою.
  • -> (доступ до члена): доступ до члена структури або класу через вказівник на нього (лише для значущих типів).
  • [] (індексація): доступ до елементів масиву через вказівник (як у C/C++).

2. Фіксовані та виділені блоки памʼяті

Коли ви працюєте з вказівниками, важливо, щоб обʼєкт, на який посилається вказівник, не переміщувався збирачем сміття під час роботи з ним. Для цього використовують ключові слова fixed і stackalloc.

Оператор fixed

Оператор fixed «закріплює» змінну посилального типу в памʼяті, запобігаючи її переміщенню збирачем сміття на час виконання fixed-блоку. Це критично важливо, коли ви передаєте вказівники на керовані обʼєкти в некерований код або працюєте з ними безпосередньо.

Приклад: Використання fixed з масивами


public unsafe class FixedExample
{
    public static void ProcessFixedArray()
    {
        byte[] buffer = new byte[10];
        // Заповнюємо буфер
        for (int i = 0; i < buffer.Length; i++)
        {
            buffer[i] = (byte)(i + 1);
        }

        unsafe
        {
            // Фіксуємо масив у пам'яті, отримуємо вказівник на його перший елемент
            fixed (byte* p = buffer)
            {
                // Тепер можна безпечно працювати з p, знаючи, що масив не буде переміщений
                Console.WriteLine($"Значення першого байта: {*p}");
                Console.WriteLine($"Значення другого байта: {*(p + 1)}");
                // Можна передати 'p' у нативну функцію, яка очікує вказівник
            } // Після виходу з блоку 'fixed' масив може бути переміщений GC
        }
    }
}

fixed також можна використовувати з полями структур і з рядками.

Виділення памʼяті на стеку: stackalloc

stackalloc дозволяє виділити блок памʼяті на стеку. Це дуже швидко, але памʼять доступна лише до завершення поточного методу. Виділена памʼять не керується збирачем сміття.

  • Переваги: дуже швидке виділення, відсутність накладних витрат GC, детерміноване звільнення памʼяті.
  • Недоліки: обмежений розмір (стек порівняно малий), ризик переповнення стеку (StackOverflowException) за надто великих виділень.
  • Використання: оптимально для невеликих, нетривалих буферів.

Приклад: Використання stackalloc


public unsafe class StackAllocExample
{
    public static void ProcessStackAlloc()
    {
        unsafe
        {
            // Виділяємо 10 int-ів на стеку
            int* numbers = stackalloc int[10];

            for (int i = 0; i < 10; i++)
            {
                numbers[i] = i * 10;
            }

            Console.WriteLine($"Значення першого елемента: {numbers[0]}");
            Console.WriteLine($"Значення п'ятого елемента: {numbers[4]}");
        } // Пам'ять автоматично звільняється при виході з цього методу.
    }
}

Оператор stackalloc можна використовувати зі Span<T>, що робить роботу з такою памʼяттю безпечнішою, адже Span<T> — це керований тип (структура), який надає безпечний доступ до суцільних блоків памʼяті. Докладніше про Span — у наступній лекції.

Приклад: stackalloc зі Span<T>


using System;

public class StackAllocWithSpan
{
    public static void DemoSpanStackAlloc()
    {
        // Виділення на стеку, але працюємо через безпечний Span<int>
        Span<int> buffer = stackalloc int[10];

        for (int i = 0; i < buffer.Length; i++)
        {
            buffer[i] = i * 2;
        }

        Console.WriteLine($"Перший елемент Span: {buffer[0]}");
        Console.WriteLine($"Останній елемент Span: {buffer[9]}");

        // Span<T> можна передавати в методи, які його приймають
        PrintSpan(buffer);
    }

    public static void PrintSpan(Span<int> s)
    {
        foreach (var item in s)
        {
            Console.Write($"{item} ");
        }
        Console.WriteLine();
    }
}

Це гібридний підхід: виділення на стеку (низькорівнево), але безпечний доступ через Span<T> (високорівнево).

3. Взаємодія з некерованим кодом (P/Invoke)

Platform Invoke (P/Invoke) — це механізм, який дозволяє C#-коду викликати функції з некерованих бібліотек (наприклад, DLL-файлів Windows API або файлів .so у Linux). Це фундаментальний аспект низькорівневого керування памʼяттю, адже ви часто передаватимете вказівники на дані між керованим і некерованим світами.

Оголошення зовнішніх функцій

Використовуйте атрибут DllImport для оголошення статичних extern-методів, що відповідають нативним функціям.

Приклад 3.1: Виклик функції Windows API


using System.Runtime.InteropServices;

public class PInvokeExample
{
    // Імпортуємо нативну функцію MessageBox з user32.dll
    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    public static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);

    public static void ShowMessageBox()
    {
        // Викликаємо нативну функцію
        MessageBox(IntPtr.Zero, "Привіт з C#!", "Заголовок вікна", 0);
    }
}

Маршалінг даних

Під час виклику нативних функцій .NET Runtime виконує маршалінг — перетворення типів даних між керованим і некерованим форматами. Наприклад, string у C# може бути замаршалений як char* або wchar_t* у C++.

Для складнішого маршалінгу можна використовувати атрибут MarshalAs і клас Marshal.

Приклад: Маршалінг структур


using System.Runtime.InteropServices;

// Цю структуру буде замаршалено в нативну структуру
[StructLayout(LayoutKind.Sequential)] // Вказує, що поля мають бути розташовані послідовно
public struct NativePoint
{
    public int X;
    public int Y;
}

public class StructMarshalExample
{
    [DllImport("your_native_lib.dll")] // Приклад: функція в нативній бібліотеці
    public static extern void ProcessPoint(NativePoint point);

    [DllImport("your_native_lib.dll")]
    public static extern void FillPoint(out NativePoint point); // Приймає вказівник на структуру

    public static void DemoStructMarshal()
    {
        NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
        ProcessPoint(myPoint); // Структуру буде замаршалено за значенням (копія)

        NativePoint resultPoint;
        FillPoint(out resultPoint); // Структуру буде заповнено нативною функцією
        Console.WriteLine($"Point from native: ({resultPoint.X}, {resultPoint.Y})");
    }
}

4. GCHandle і закріплення обʼєктів

GCHandle — це структура, яка дозволяє отримати «дескриптор» (handle) обʼєкта в керованій купі памʼяті і, за потреби, тимчасово закріпити його, запобігаючи переміщенню або збиранню GC. Це корисно, коли потрібно передати стабільний вказівник на керований обʼєкт у некерований код.

Приклад: Закріплення обʼєкта за допомогою GCHandle


using System.Runtime.InteropServices;

public class GCHandleExample
{
    public static void PinObject()
    {
        byte[] data = new byte[100];
        GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // Закріплюємо масив у пам'яті
        try
        {
            IntPtr pointer = handle.AddrOfPinnedObject(); // Отримуємо вказівник на закріплений об'єкт
            Console.WriteLine($"Адреса закріпленого масиву: {pointer:X}");

            // Тепер 'pointer' можна безпечно передати в нативну функцію.
            // Нативна функція може працювати з цією пам'яттю напряму.

            Marshal.WriteByte(pointer, 0, 255); // Змінюємо перший байт через вказівник
            Console.WriteLine($"Перший байт масиву: {data[0]}"); // Вивід: 255
        }
        finally
        {
            if (handle.IsAllocated)
            {
                handle.Free(); // Звільняємо дескриптор, дозволяємо GC знову керувати об'єктом
            }
        }
    }
}

Детальний розбір P/Invoke і GCHandle виходить за рамки цього курсу, проте ці інструменти можуть бути дуже корисними, коли потрібно напряму викликати функції бібліотек Windows. Принаймні тепер ви знаєте, у якому напрямі рухатися далі.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ