CodeGym /행동 /C# SELF /C#에서의 저수준 메모리 관리

C#에서의 저수준 메모리 관리

C# SELF
레벨 65 , 레슨 1
사용 가능

1. 소개

이전 강의에서는 C#과 .NET Runtime이 메모리 관리의 대부분을 어떻게 맡는지 이야기했습니다. 하지만 때로는 성능 최적화나 네이티브 코드(예: C++로 작성된 시스템 API)와의 상호작용을 위해 더 저수준의 메모리 제어가 필요하거나 원할 때가 있습니다. 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) 및 할당된 메모리 블록

포인터 작업을 할 때는 포인터가 가리키는 객체가 작업 도중 GC에 의해 이동되지 않도록 하는 것이 중요합니다. 이를 위해 fixedstackalloc 키워드를 사용합니다.

fixed 연산자

fixed 연산자는 참조 타입 변수를 메모리에 "고정"하여 GC가 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
        {
            // 스택에 int 10개 할당
            int* numbers = stackalloc int[10];

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

            Console.WriteLine($"첫 요소 값: {numbers[0]}");
            Console.WriteLine($"다섯 번째 요소 값: {numbers[4]}");
        } // 이 메서드를 벗어나면 메모리는 자동으로 해제됨.
    }
}

stackallocSpan<T>와 함께 사용하면 더 안전하게 다룰 수 있습니다. Span<T>는 연속된 메모리 블록에 안전하게 접근하도록 해주는 관리형 타입(구조체)입니다. Span에 대해서는 다음 강의에서 더 자세히 다룹니다.

예제: stackallocSpan<T>


using System;

public class StackAllocWithSpan
{
    public static void DemoSpanStackAlloc()
    {
        // 스택에 할당하지만 안전한 Span
  
   로 작업 Span
   
     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
    
     를 받는 메서드에 전달할 수 있음 PrintSpan(buffer); } public static void PrintSpan(Span
     
       s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } } 
     
    
   
  

이건 하이브리드 접근법입니다: 스택 할당(저수준) + Span<T>를 통한 안전한 접근(고수준).

3. 비관리 코드와의 상호작용 (P/Invoke)

Platform Invoke (P/Invoke)는 C# 코드가 비관리 라이브러리(예: Windows API의 DLL 파일이나 Linux의 .so 파일)에서 함수를 호출할 수 있게 하는 메커니즘입니다. 관리/비관리 경계에서 데이터를 주고받을 때 포인터를 전달하는 일이 자주 발생하므로 저수준 메모리 관리와 밀접한 관련이 있습니다.

외부 함수 선언

네이티브 함수를 대응시키기 위해 DllImport 어트리뷰트를 사용해 정적 extern 메서드를 선언합니다.

예제 3.1: Windows API 함수 호출


using System.Runtime.InteropServices;

public class PInvokeExample
{
    // user32.dll에서 네이티브 함수 MessageBox 가져오기
    [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은 관리형 타입과 비관리형 타입 간의 변환, 즉 marshaling을 수행합니다. 예를 들어 C#의 string은 C++에서 char*wchar_t*로 마샬될 수 있습니다.

더 복잡한 마샬링이 필요하면 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($"네이티브에서 온 포인트: ({resultPoint.X}, {resultPoint.Y})");
    }
}

4. GCHandle와 객체 고정

GCHandle는 관리 힙의 객체에 대한 "핸들"을 얻고 필요시 일시적으로 고정하여 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 라이브러리를 직접 호출해야 할 때 매우 유용하니 더 깊게 공부해보세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION