C# unsafe 코드와 stackalloc으로 고성능 메모리 관리
C#은 기본적으로 안전한 관리 코드(managed code)를 실행하지만, 성능이 극도로 중요한 상황에서는 unsafe 컨텍스트를 통해 포인터 연산, 스택 메모리 할당, 직접 메모리 조작을 수행할 수 있습니다.
1. unsafe 컨텍스트 활성화
섹션 제목: “1. unsafe 컨텍스트 활성화”<PropertyGroup> <AllowUnsafeBlocks>true</AllowUnsafeBlocks></PropertyGroup>unsafe void ProcessData(byte* data, int length){ for (int i = 0; i < length; i++) data[i] ^= 0xFF;}
// 또는 클래스/메서드 전체에 적용unsafe class NativeProcessor { }2. stackalloc — 스택 메모리 할당
섹션 제목: “2. stackalloc — 스택 메모리 할당”힙 할당 없이 스택에 배열을 할당합니다. GC 압력 없음, 함수 종료 시 자동 해제.
// unsafe 없이도 Span<T>와 함께 사용 가능 (C# 7.2+)Span<int> buffer = stackalloc int[256];
// 초기화와 함께Span<byte> zeros = stackalloc byte[64]; // 자동 0 초기화
// unsafe 포인터 방식unsafe{ int* arr = stackalloc int[10]; for (int i = 0; i < 10; i++) arr[i] = i * i;}2.1 stackalloc 한계
섹션 제목: “2.1 stackalloc 한계”// 스택 크기는 보통 1MB — 큰 배열은 힙 사용int size = GetDynamicSize(); // 런타임 크기Span<byte> buf = size <= 1024 ? stackalloc byte[size] : new byte[size]; // 크면 힙 할당3. fixed 구문 — 관리 객체 고정
섹션 제목: “3. fixed 구문 — 관리 객체 고정”GC가 객체를 이동하지 못하도록 고정하여 포인터를 안전하게 사용합니다.
byte[] managedArray = new byte[1024];
unsafe{ fixed (byte* ptr = managedArray) { // ptr은 GC 이동으로부터 보호됨 ProcessNativeData(ptr, managedArray.Length); } // fixed 블록 종료 시 고정 해제}
// 문자열 고정string text = "Hello, World!";unsafe{ fixed (char* p = text) { char* current = p; while (*current != '\0') { Console.Write(*current); current++; } }}4. 포인터 연산
섹션 제목: “4. 포인터 연산”unsafe{ int[] arr = { 10, 20, 30, 40, 50 };
fixed (int* p = arr) { int* current = p; int* end = p + arr.Length;
while (current < end) { Console.Write(*current + " "); current++; // sizeof(int) = 4 바이트 이동 } }
// 포인터 캐스팅 float f = 3.14f; float* fp = &f; int* ip = (int*)fp; // 비트 패턴 재해석 Console.WriteLine($"float bits: {*ip:X8}");}5. Span<T>와 통합 (권장 패턴)
섹션 제목: “5. Span<T>와 통합 (권장 패턴)”현대 C#에서는 unsafe 대신 Span<T>를 통해 안전하게 저수준 메모리를 다룰 수 있습니다.
// Span으로 스택 메모리 래핑 (unsafe 불필요)void ProcessBuffer(){ Span<byte> buffer = stackalloc byte[256];
// 슬라이싱 Span<byte> header = buffer[..4]; Span<byte> body = buffer[4..];
// 구조체를 바이트로 재해석 Span<int> intView = System.Runtime.InteropServices.MemoryMarshal .Cast<byte, int>(buffer); intView[0] = 0x12345678;}5.1 MemoryMarshal 활용
섹션 제목: “5.1 MemoryMarshal 활용”using System.Runtime.InteropServices;
// 구조체를 바이트 스팬으로 변환struct Header { public int Magic; public int Version; }
Header h = new() { Magic = 0xDEADBEEF, Version = 1 };Span<byte> bytes = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref h, 1));// bytes에 직접 접근하여 직렬화/역직렬화
// 타입 캐스팅float[] floats = { 1.0f, 2.0f, 3.0f };Span<byte> rawBytes = MemoryMarshal.AsBytes<float>(floats);6. NativeMemory — .NET 6+ 비관리 메모리
섹션 제목: “6. NativeMemory — .NET 6+ 비관리 메모리”using System.Runtime.InteropServices;
// 비관리 힙 할당void* ptr = NativeMemory.Alloc(1024);NativeMemory.Clear(ptr, 1024);
try{ // ptr 사용 unsafe { byte* data = (byte*)ptr; data[0] = 42; }}finally{ NativeMemory.Free(ptr); // 반드시 해제}
// 정렬 메모리void* aligned = NativeMemory.AlignedAlloc(256, 64); // 64바이트 정렬NativeMemory.AlignedFree(aligned);7. 성능 측정 예
섹션 제목: “7. 성능 측정 예”using BenchmarkDotNet.Attributes;
public class MemoryBenchmarks{ private const int Size = 1024;
[Benchmark(Baseline = true)] public int ArrayAlloc() { int[] arr = new int[Size]; int sum = 0; for (int i = 0; i < Size; i++) sum += arr[i]; return sum; }
[Benchmark] public int StackAlloc() { Span<int> arr = stackalloc int[Size]; int sum = 0; for (int i = 0; i < Size; i++) sum += arr[i]; return sum; }}// stackalloc은 GC 할당 없음 → 짧은 배열에서 빠름// 단, 큰 배열(수십 KB 이상)은 스택 오버플로 위험8. 주의사항
섹션 제목: “8. 주의사항”// 1. stackalloc 크기는 작게 유지 (일반적으로 1KB 이하 권장)// 2. fixed 블록 내에서 예외 발생 시도 고정 해제됨 (try/finally 불필요)// 3. 포인터를 fixed 블록 외부로 반환하면 UBunsafe{ byte[] arr = new byte[10]; byte* dangerous; fixed (byte* p = arr) { dangerous = p; // 위험: fixed 블록 밖에서 사용하면 GC 이동 후 무효 } // dangerous 사용 금지}| 기법 | 용도 | GC 영향 |
|---|---|---|
stackalloc | 짧은 수명의 소형 버퍼 | 없음 |
fixed | 관리 배열/객체 포인터 전달 | 일시 고정 |
NativeMemory | 대형 비관리 버퍼 | 없음 (수동 해제) |
Span<T> + MemoryMarshal | 안전한 저수준 메모리 접근 | 최소화 |